From e4719cb9ae77bb0ef8c296edd6ae02c720b623e3 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 19 Sep 2026 12:27:29 +0200 Subject: [PATCH] Rework balances around per-parent days left and deduce the partner from FK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 697 +++++++----------- PLANNING_ENGINE_SPEC.md | 298 +++++--- README.md | 30 +- backend/.env.example | 3 + backend/README.md | 49 +- backend/app/config.py | 16 +- backend/app/db/connection.py | 9 +- backend/app/dependencies.py | 4 +- backend/app/entities/leave_wish.py | 15 + backend/app/entities/parental_leave.py | 2 +- backend/app/entities/workspace.py | 1 - backend/app/models/leave_wish.py | 12 + backend/app/models/parental_leave.py | 27 +- backend/app/models/plan.py | 105 +++ backend/app/models/workspace.py | 3 - .../sqlite/leave_wish_repository.py | 9 +- .../sqlite/workspace_repository.py | 10 +- backend/app/routers/fetch_data.py | 58 +- backend/app/routers/leave_wishes.py | 2 + backend/app/routers/plan.py | 13 +- backend/app/routers/workspace.py | 2 +- backend/app/scrapers/fk_scraper.py | 282 +++++-- backend/app/services/allocation_service.py | 559 +++++++++++--- backend/app/services/analytics_service.py | 65 +- backend/app/services/fetch_data_service.py | 148 ++-- backend/app/services/leave_snapshot.py | 187 +++++ backend/app/services/leave_wish_service.py | 10 + backend/app/services/planning_service.py | 309 +++++++- backend/app/services/tips_service.py | 32 +- backend/app/services/workspace_service.py | 20 +- backend/app/utils/tip_predicates.py | 32 +- .../migrations/versions/001_initial_schema.py | 115 ++- .../versions/002_add_deduced_source.py | 134 ++++ .../002_planning_mode_and_preschool_days.py | 42 -- .../003_rename_break_period_to_leave_wish.py | 134 ---- .../versions/004_add_day_override.py | 54 -- ...005_add_personal_data_min_vacation_days.py | 44 -- .../006_add_leave_wish_window_mode.py | 45 -- .../007_add_leave_wish_name_message_key.py | 51 -- .../008_add_wish_link_and_target_days.py | 68 -- .../versions/009_drop_planning_goal.py | 50 -- .../versions/010_add_tip_dismissal.py | 59 -- .../migrations/versions/011_add_indexes.py | 99 --- backend/scripts/demo_plan.py | 3 +- backend/scripts/seed_demo.py | 12 +- backend/tests/conftest.py | 6 +- .../tests/fixtures/fk_overview_synthetic.html | 42 +- .../tests/integration/test_user_journey.py | 97 ++- backend/tests/unit/test_authz.py | 7 +- backend/tests/unit/test_config.py | 10 + backend/tests/unit/test_db_unit_of_work.py | 4 +- .../unit/test_default_tier_pool_share.py | 36 + backend/tests/unit/test_fetch_data_router.py | 276 ++++++- backend/tests/unit/test_fk_scraper.py | 370 +++++++++- .../tests/unit/test_leave_wish_repository.py | 85 +++ backend/tests/unit/test_leave_wish_router.py | 151 ++++ backend/tests/unit/test_plan_router.py | 153 ++++ .../tests/unit/test_planning_service_apply.py | 19 +- .../tests/unit/test_planning_service_sync.py | 547 +++++++++++++- .../test_allocation_input_loader.py | 126 +++- .../test_allocation_once_and_wrap.py | 3 - .../test_services/test_allocation_service.py | 461 ++++++++++-- .../test_allocation_service_branches.py | 26 +- .../test_allocation_swap_fairness.py | 1 - .../test_services/test_analytics_service.py | 81 +- .../test_services/test_fetch_data_service.py | 284 ++++++- .../unit/test_services/test_leave_snapshot.py | 285 +++++++ .../unit/test_services/test_tips_service.py | 35 +- backend/tests/unit/test_workspace_service.py | 83 +-- ideas.md | 4 + observability-feature.md | 453 ++++++++++++ 71 files changed, 5705 insertions(+), 1859 deletions(-) create mode 100644 backend/app/services/leave_snapshot.py create mode 100644 backend/migrations/versions/002_add_deduced_source.py delete mode 100644 backend/migrations/versions/002_planning_mode_and_preschool_days.py delete mode 100644 backend/migrations/versions/003_rename_break_period_to_leave_wish.py delete mode 100644 backend/migrations/versions/004_add_day_override.py delete mode 100644 backend/migrations/versions/005_add_personal_data_min_vacation_days.py delete mode 100644 backend/migrations/versions/006_add_leave_wish_window_mode.py delete mode 100644 backend/migrations/versions/007_add_leave_wish_name_message_key.py delete mode 100644 backend/migrations/versions/008_add_wish_link_and_target_days.py delete mode 100644 backend/migrations/versions/009_drop_planning_goal.py delete mode 100644 backend/migrations/versions/010_add_tip_dismissal.py delete mode 100644 backend/migrations/versions/011_add_indexes.py create mode 100644 backend/tests/unit/test_default_tier_pool_share.py create mode 100644 backend/tests/unit/test_services/test_leave_snapshot.py create mode 100644 observability-feature.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f169fa5..7d0b0cb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,9 +2,11 @@ > Parts of this document are superseded — search for **SUPERSEDED** for the current behaviour. -**Date:** 2026-05-31 (updated 2026-09-13) -**Status:** Planning phase - reviewed, decisions incorporated (see §0). Planning engine drafted in **PLANNING_ENGINE_SPEC.md**. -**Tech Stack:** TypeScript + React + Vite (frontend), FastAPI + Python 3.14 (backend), SQLite → Postgres +**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) --- @@ -26,7 +28,7 @@ A parental leave planning web app that helps Swedish parents plan and optimize t - 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). +**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). @@ -36,7 +38,7 @@ A parental leave planning web app that helps Swedish parents plan and optimize t - With joint custody the default split is **240 days per parent**. - **90 of each parent's days are reserved** (non-transferable "reserverade dagar"); the remaining 150 can be transferred to the other parent. - **Dubbeldagar:** parents may take leave for the same child on the same day for up to **60 days**, only during the child's **first 15 months**. -- **Age-4 rule:** from the child's 4th birthday, at most **96 days** remain usable (the rest expire). All days must be used by age 12. +- **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. @@ -45,200 +47,47 @@ A parental leave planning web app that helps Swedish parents plan and optimize t ## 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 -``` -backend/ -├── app/ -│ ├── __init__.py -│ ├── main.py # FastAPI app initialization -│ ├── config.py # Environment config -│ ├── dependencies.py # Dependency injection setup -│ ├── models/ # Pydantic schemas for API -│ │ ├── __init__.py -│ │ ├── personal_data.py -│ │ ├── user.py -│ │ ├── workspace.py -│ │ ├── child.py -│ │ ├── parental_leave.py -│ │ ├── vacation_days.py -│ │ └── planned_leave.py -│ ├── entities/ # Domain entities (business logic) -│ │ ├── __init__.py -│ │ ├── personal_data.py -│ │ ├── user.py -│ │ ├── workspace.py -│ │ ├── workspace_invitation.py -│ │ ├── leave_wish.py # Recurring intent to be home (replaced BreakPeriod) -│ │ ├── child.py -│ │ ├── day_override.py # Workspace calendar corrections -│ │ ├── parental_leave.py -│ │ ├── vacation_days.py -│ │ └── planned_leave.py -│ ├── repositories/ -│ │ ├── __init__.py -│ │ ├── base.py # Abstract Repository[T] -│ │ ├── personal_data_repository.py -│ │ ├── user_repository.py -│ │ ├── workspace_repository.py -│ │ ├── child_repository.py -│ │ ├── parental_leave_repository.py -│ │ ├── vacation_days_repository.py -│ │ └── planned_leave_repository.py -│ ├── repositories_impl/ -│ │ ├── __init__.py -│ │ ├── sqlite/ -│ │ │ ├── __init__.py -│ │ │ ├── personal_data_repository.py -│ │ │ ├── user_repository.py -│ │ │ └── ... (one per entity) -│ │ └── postgres/ -│ │ ├── __init__.py -│ │ ├── personal_data_repository.py -│ │ └── ... (identical interface, different impl) -│ ├── services/ -│ │ ├── __init__.py -│ │ ├── user_service.py -│ │ ├── workspace_service.py # incl. invitations -│ │ ├── fetch_data_service.py # FK scraping + manual entry (stable contract) -│ │ ├── planning_service.py # Calendar planning logic -│ │ ├── allocation_service.py # Pure planning-engine: Allocator + AllocationInputLoader -│ │ ├── analytics_service.py # Metrics + days-left-at-age-4 calculation -│ │ └── tips_service.py # Optimization tips / SGI rules engine -│ ├── routers/ -│ │ ├── __init__.py -│ │ ├── auth.py -│ │ ├── workspace.py -│ │ ├── fetch_data.py -│ │ ├── planned_days.py -│ │ ├── vacation_days.py -│ │ ├── analytics.py -│ │ └── settings.py -│ ├── scrapers/ -│ │ ├── __init__.py -│ │ └── fk_scraper.py # Försäkringskassan headless browser -│ ├── data/ -│ │ └── swedish_calendar.json # 10-year cache (2026–2035), generated by scripts/ -│ ├── db/ -│ │ ├── __init__.py -│ │ ├── connection.py # DB factory (SQLite/Postgres) -│ │ └── migrations/ # Alembic migrations -│ └── utils/ -│ ├── __init__.py -│ ├── logger.py -│ ├── errors.py -│ ├── constants.py # 480/390/90/96/60-day rules in one place -│ └── validators.py -├── tests/ -│ ├── __init__.py -│ ├── conftest.py # Pytest fixtures -│ ├── unit/ -│ │ ├── test_repositories/ -│ │ ├── test_services/ -│ │ └── test_entities/ -│ ├── integration/ -│ │ ├── test_user_flow.py -│ │ ├── test_fetch_data_flow.py -│ │ └── test_planning_flow.py -│ └── fixtures/ -│ └── mock_data.py -├── pyproject.toml # UV package management -├── uv.lock -└── README.md -``` +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 -``` -frontend/ -├── src/ -│ ├── main.tsx -│ ├── App.tsx -│ ├── vite-env.d.ts -│ ├── types/ # Shared TypeScript interfaces -│ │ ├── index.ts -│ │ ├── entities.ts # PersonalData, User, Workspace, etc. -│ │ ├── api.ts # API request/response types -│ │ └── ui.ts # Component prop types -│ ├── services/ -│ │ ├── api.ts # API client (typed) -│ │ ├── storage.ts # Local storage abstraction -│ │ └── errors.ts # Error handling -│ ├── hooks/ -│ │ ├── useAuth.ts -│ │ ├── useWorkspace.ts -│ │ ├── usePlannedDays.ts -│ │ ├── useParentalLeave.ts -│ │ ├── useVacationDays.ts -│ │ ├── useAnalytics.ts -│ │ └── useOptimizationTips.ts -│ ├── context/ -│ │ ├── AuthContext.tsx -│ │ └── WorkspaceContext.tsx -│ ├── pages/ -│ │ ├── Dashboard.tsx -│ │ ├── Calendar.tsx -│ │ ├── Settings.tsx -│ │ ├── Tips.tsx -│ │ └── Auth.tsx -│ ├── components/ -│ │ ├── common/ -│ │ │ ├── Header.tsx -│ │ │ ├── Footer.tsx -│ │ │ ├── Layout.tsx -│ │ │ └── LoadingSpinner.tsx -│ │ ├── calendar/ -│ │ │ ├── CalendarView.tsx # Google Calendar-like -│ │ │ ├── CalendarDay.tsx -│ │ │ ├── CalendarEvent.tsx -│ │ │ └── DayPlanner.tsx -│ │ ├── analytics/ -│ │ │ ├── OverviewMetrics.tsx -│ │ │ ├── DaysBreakdown.tsx -│ │ │ └── ProgressChart.tsx -│ │ ├── settings/ -│ │ │ ├── VacationDaysInput.tsx -│ │ │ ├── ChildBirthdayInput.tsx # incl. preschool start date -│ │ │ ├── ParentProfileInput.tsx # salary_type, employment_status -│ │ │ ├── PlanningGoalSelector.tsx # max_time / max_money / balanced -│ │ │ ├── BreakPeriodsInput.tsx # editable summer/winter/... periods -│ │ │ └── WorkspaceSettings.tsx -│ │ ├── fetch-data/ -│ │ │ ├── FetchDataButton.tsx -│ │ │ ├── QRCodeModal.tsx -│ │ │ ├── FetchingProgress.tsx -│ │ │ └── FetchDataStatus.tsx -│ │ └── tips/ -│ │ ├── TipsPanel.tsx -│ │ ├── TipCard.tsx -│ │ └── OptimizationEngine.tsx -│ ├── utils/ -│ │ ├── dates.ts -│ │ ├── calculations.ts -│ │ ├── validators.ts -│ │ └── formatting.ts -│ ├── stores/ # State management (if needed) -│ │ └── index.ts -│ └── styles/ -│ ├── global.css -│ └── components.module.css -├── tests/ -│ ├── unit/ -│ │ ├── hooks/ -│ │ ├── utils/ -│ │ ├── services/ -│ │ └── components/ -│ ├── integration/ -│ │ └── flows/ -│ ├── fixtures/ -│ │ └── mockData.ts -│ └── setup.ts -├── .eslintrc.json -├── tsconfig.json -├── vite.config.ts -├── vitest.config.ts -└── package.json -``` +`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. --- @@ -247,12 +96,12 @@ frontend/ Försäkringskassan exposes **no public API**, so the primary mechanism is a headless-browser scraper that logs in via BankID and reads the parental-leave figures. This is the **single highest-risk component** of the project; the architecture treats it accordingly. **Risks to acknowledge (not ignore):** -- **Terms of Service / legality:** automated access and BankID automation may violate Försäkringskassan's and BankID's terms. This needs a deliberate decision (and ideally legal review) before shipping publicly. The scraper must only ever act on behalf of the authenticated user, on their own data, with their active consent each session — never store BankID credentials. +- **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 the same numbers they see on FK (`total_days`, `days_used`, reserved/transferable split, dubbeldagar left, tier breakdown). These persist as a `ParentalLeaveFetch` with `source = 'manual'`. +- 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 …"). @@ -272,13 +121,17 @@ The original draft conflated "parent" with "registered user." They are separated - **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).** `goal_override` was removed entirely (migration 009). The allocator now applies a single policy based solely on `salary_type`. `PlannedLeaveDay` gained `wish_id` (migration 008) 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. +> **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 @@ -324,9 +177,10 @@ class Workspace: name: str # Drives which tips are emphasized (§6). Not a hard constraint. planning_goal: str = "balanced" # 'max_time' | 'max_money' | 'balanced' - # Explicit: whether to plan for one parent (solo) or two (couple). - # Not inferred from member count; set by the workspace creator. - planning_mode: str = "solo" # 'solo' | 'couple' + # 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 @@ -375,8 +229,18 @@ class LeaveWish: parent_mode: str # 'either' | 'both' | 'specific' parent_personal_data_uuid: UUID | None # required when parent_mode == 'specific' - # Per-window goal override (nullable, no UI in v1). - goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None + # '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 @@ -403,7 +267,8 @@ class ParentalLeaveFetch: personal_data_uuid: UUID # Which parent workspace_id: UUID child_id: UUID - source: str # 'scraped' | 'manual' — provenance of these numbers + 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 @@ -462,150 +327,20 @@ class PlannedLeaveDay: ### 3.2 Database Schema (SQLite/Postgres) -> **SUPERSEDED (2026-09-15).** `planned_leave_day` gained `wish_id` (migration 008) to link persisted days back to the wish that produced them, and `is_pinned` to mark hand-edited days the allocator should not retype. `workspace.planning_goal` was removed entirely (migration 009). `leave_wish` gained nullable `target_days` (migration 008, overrides `target_weeks`), and `goal_override` was removed entirely (migration 009). +> **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. -```sql --- personal_data: a parent profile (login optional). Isolated for GDPR. -CREATE TABLE personal_data ( - uuid TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - name TEXT NOT NULL, - salary_type TEXT NOT NULL DEFAULT 'monthly' CHECK (salary_type IN ('monthly','hourly')), - employment_status TEXT NOT NULL DEFAULT 'employed' - CHECK (employment_status IN ('employed','jobseeking','none')), - personnummer_enc TEXT, -- encrypted/HMAC, nullable; prefer NULL (see §11) - created_at TIMESTAMP NOT NULL, - deleted_at TIMESTAMP, - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE -); - --- user: a login linked 1:1 to a parent profile (workspace derived via personal_data) -CREATE TABLE "user" ( - id TEXT PRIMARY KEY, - personal_data_uuid TEXT NOT NULL UNIQUE, - email TEXT NOT NULL UNIQUE, - hashed_password TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE -); - --- workspace: Shared planning space (1–2 parent profiles) -CREATE TABLE workspace ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - planning_goal TEXT NOT NULL DEFAULT 'balanced' - CHECK (planning_goal IN ('max_time','max_money','balanced')), - planning_mode TEXT NOT NULL DEFAULT 'solo' - CHECK (planning_mode IN ('solo','couple')), - created_at TIMESTAMP NOT NULL -); - --- workspace_invitation: lets a partner join an existing workspace -CREATE TABLE workspace_invitation ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - token_hash TEXT NOT NULL UNIQUE, -- store only a hash of the single-use token - invited_by_user_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('pending','accepted','revoked','expired')), - expires_at TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL, - accepted_at TIMESTAMP, - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, - FOREIGN KEY (invited_by_user_id) REFERENCES "user"(id) ON DELETE CASCADE -); - --- leave_wish: recurring family intent to be home (input to planning engine) -CREATE TABLE leave_wish ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - child_id TEXT, -- NULL = family-wide, else tied to child's pools - name TEXT NOT NULL, - window_start_month INTEGER NOT NULL CHECK (window_start_month BETWEEN 1 AND 12), - window_start_day INTEGER NOT NULL CHECK (window_start_day BETWEEN 1 AND 31), - window_end_month INTEGER NOT NULL CHECK (window_end_month BETWEEN 1 AND 12), - window_end_day INTEGER NOT NULL CHECK (window_end_day BETWEEN 1 AND 31), - target_weeks INTEGER NOT NULL, - recurrence TEXT NOT NULL CHECK (recurrence IN ('yearly', 'once')), - first_year INTEGER NOT NULL, - last_year INTEGER, -- NULL = until child's 4th birthday (if child_id set) - priority INTEGER NOT NULL, - parent_mode TEXT NOT NULL CHECK (parent_mode IN ('either', 'both', 'specific')), - parent_personal_data_uuid TEXT, -- required when parent_mode = 'specific' - goal_override TEXT, -- 'max_time' | 'max_money' | 'balanced' | NULL - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, - FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, - FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE SET NULL -); - --- child: One per child, multi-child ready -CREATE TABLE child ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - name TEXT NOT NULL, - birth_date DATE NOT NULL, - preschool_start_date DATE, - preschool_planning_days_per_year INTEGER NOT NULL DEFAULT 6, - created_at TIMESTAMP NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE -); - --- parental_leave_fetch: Cached Försäkringskassan data (or manual entry) -CREATE TABLE parental_leave_fetch ( - id TEXT PRIMARY KEY, - personal_data_uuid TEXT NOT NULL, - workspace_id TEXT NOT NULL, - child_id TEXT NOT NULL, - source TEXT NOT NULL CHECK (source IN ('scraped', 'manual')), - fetched_at TIMESTAMP NOT NULL, - total_days INTEGER NOT NULL, - days_used INTEGER NOT NULL, - days_left INTEGER NOT NULL, - reserved_days_total INTEGER NOT NULL DEFAULT 90, - reserved_days_left INTEGER NOT NULL, - transferable_days_left INTEGER NOT NULL, - dubbeldagar_left INTEGER NOT NULL, - tier_data TEXT NOT NULL, -- JSON string (sjukpenningnivå + lägstanivå) - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, - FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE -); - --- vacation_days: Manual input -CREATE TABLE vacation_days ( - id TEXT PRIMARY KEY, - personal_data_uuid TEXT NOT NULL, - workspace_id TEXT NOT NULL, - year INTEGER NOT NULL, - total_days INTEGER NOT NULL, - saved_days_in INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - UNIQUE(personal_data_uuid, year), - FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE -); - --- planned_leave_day: Calendar events -CREATE TABLE planned_leave_day ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - child_id TEXT NOT NULL, - parent_personal_data_uuid TEXT NOT NULL, - date DATE NOT NULL, - day_type TEXT NOT NULL CHECK (day_type IN ('sjukpenning', 'lagsta', 'vacation')), - is_dubbeldag INTEGER NOT NULL DEFAULT 0, -- 0/1; both parents on same child-day - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - UNIQUE(workspace_id, child_id, date, parent_personal_data_uuid), - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, - FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, - FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE -); -``` +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. --- @@ -783,62 +518,86 @@ class PlanningService: ## 5. API Endpoints -> **SUPERSEDED (2026-09-15).** The `planning_goal` endpoint was removed entirely (migration 009). +> **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 -POST /api/workspace # Create new -GET /api/workspace/{workspace_id}/members # List parent profiles (1–2) -POST /api/workspace/{workspace_id}/parents # Add placeholder parent (solo planning) +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/workspace/{workspace_id}/invitations # Create single-use invite (returns token) POST /api/invitations/redeem # Redeem token → join workspace -DELETE /api/invitations/{invitation_id} # Revoke pending invite +DELETE /api/invitations/{invitation_id} # Revoke pending invite -# Fetch Data from Försäkringskassan -POST /api/fetch-data/start # Initialize headless browser +# 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 -GET /api/parental-leave/{workspace_id}/{child_id} -GET /api/parental-leave/{workspace_id}/{child_id}/history # Fetch history +# 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 # Add planned day -GET /api/planned-days/{workspace_id}/{child_id} # Get month/year -DELETE /api/planned-days/{planned_day_id} -PUT /api/planned-days/{planned_day_id} +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 +# Vacation Days (no PUT — POST upserts by (parent, year)) POST /api/vacation-days -GET /api/vacation-days/{workspace_id}/{personal_data_uuid}/{year} -PUT /api/vacation-days/{vacation_days_id} +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) +GET /api/calendar/days # Calendar metadata (röda dagar, klämdagar, overrides) — §13.3 # Analytics/Dashboard -GET /api/analytics/{workspace_id}/overview -GET /api/analytics/{workspace_id}/breakdown # Days by type/parent -GET /api/analytics/{workspace_id}/projection # Days left at age 4 +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/Optimization +# Tips GET /api/tips/{workspace_id} - -# Settings -PUT /api/settings/child/{child_id} # Set birth date + preschool start date -PUT /api/settings/parent/{personal_data_uuid} # Set salary_type, employment_status -PUT /api/settings/workspace/{workspace_id}/goal # Set planning_goal (max_time/max_money/balanced) +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 @@ -846,8 +605,11 @@ POST /api/leave-wishes # Add a leave wish (summer/win PUT /api/leave-wishes/{leave_wish_id} # Edit name/dates/priority/recurrence DELETE /api/leave-wishes/{leave_wish_id} # Remove a leave wish -# User -DELETE /api/user/{personal_data_uuid} # Delete account + data +# 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 ``` --- @@ -862,9 +624,9 @@ The app provides contextual recommendations. Because the app does **not** comput - 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 (migration 009). The engine now applies a single allocation policy based solely on `salary_type`, as described in PLANNING_ENGINE_SPEC.md §3.3. +> **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, plus the workspace `planning_goal`. Tips are ranked by the active goal (`max_time` surfaces day-preserving tips; `max_money` surfaces salary-protecting tips). The rules below reference helper predicates that operate on the combined plan (fetched snapshot + planned days). +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) @@ -984,7 +746,7 @@ class OptimizationTips: ] async def get_tips(self, workspace_id: UUID) -> List[Tip]: - """Generate all applicable tips for workspace, ranked by planning_goal.""" + """Generate all applicable tips for workspace.""" pass ``` @@ -1005,19 +767,25 @@ The weekend/red-day "wrapping" tips reflect a common monthly-salary pattern, but 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. It is now 48 per parent in couple mode with two parents, and 96 otherwise (commit 6da947f, `resolve_age4_savable_cap`). +> **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 96-day age-4 ceiling (or 48 in couple mode with two parents), and the age-12 hard expiry. +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`): +**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, 96) # the 96-day cap -expiring_at_B4 = max(projected_left_at_B4 - 96, 0) # LOST if not used before B4 +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. @@ -1048,7 +816,6 @@ export interface Workspace { id: string; name: string; planningGoal: 'max_time' | 'max_money' | 'balanced'; - planningMode: 'solo' | 'couple'; createdAt: Date; } @@ -1062,23 +829,30 @@ export interface Child { 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; - goalOverride: 'max_time' | 'max_money' | 'balanced' | null; + windowMode: 'fixed' | 'flexible'; // PLANNING_ENGINE_SPEC.md §10.1 createdAt: Date; updatedAt: Date; } @@ -1148,6 +922,35 @@ export interface ApiError { } ``` +### 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 @@ -1223,7 +1026,7 @@ class TestPlanningService: with pytest.raises(InsufficientDaysError): await planning_service.add_planned_day(...) -# Target: >=90% coverage +# Actual CI gate: --cov-fail-under=80 (see backend/README.md and CLAUDE.md) pytest --cov=app --cov-report=html ``` @@ -1310,7 +1113,7 @@ describe('CalendarView', () => { }); }); -// Target: >=90% coverage +// Actual gates (frontend/vitest.config.ts): lines 96% / functions 91% / branches 92% / statements 96% vitest --coverage ``` @@ -1318,38 +1121,21 @@ vitest --coverage ## 9. Implementation Roadmap -### Phase 1: Foundation (Week 1) -- [ ] Setup project structure (backend + frontend) -- [ ] Database schema + migrations -- [ ] Repository pattern + DI setup -- [ ] Basic auth (signup/login) + token strategy -- [ ] Workspace + parent profiles + partner invitations -- [ ] **Manual parental-leave entry** (the always-available data path) +> **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. -### Phase 2: Data Fetching (Week 2) -- [ ] Headless browser scraper for Försäkringskassan (behind FetchDataService contract) -- [ ] BankID QR code capture + display -- [ ] Parental leave data scraping + storage (source='scraped') -- [ ] Fetch data endpoints + frontend flow -- [ ] Legal/ToS review of scraping + BankID automation (gate before public launch) - -### Phase 3: Planning Interface (Week 3) -- [ ] Calendar component (month/week view) -- [ ] Add/edit/delete planned days -- [ ] Day type selection (sjukpenning/lägsta/vacation) -- [ ] Real-time validation against remaining days - -### Phase 4: Analytics & Tips (Week 4) -- [ ] Analytics endpoints (overview, breakdown, projection) -- [ ] Dashboard UI with metrics -- [ ] Optimization tips engine -- [ ] Tips display component - -### Phase 5: Polish & Testing (Week 5) -- [ ] Comprehensive test coverage (>=90%) -- [ ] Error handling + logging -- [ ] ESLint + code quality -- [ ] Documentation +- [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. --- @@ -1359,7 +1145,7 @@ All code must adhere to: ✅ **Backend (Python):** - Fully typed with type hints -- pytest with >=90% coverage +- 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 @@ -1369,9 +1155,9 @@ All code must adhere to: ✅ **Frontend (TypeScript/React):** - All TS/TSX files (no .js) -- Full type safety (no `any` without justification) -- Vitest with >=90% coverage -- ESLint enforced +- 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 @@ -1395,18 +1181,25 @@ All code must adhere to: - [x] Hard delete capability for data removal - [x] Cascading deletes via FK constraints - [x] Data minimization (only fetch what's needed) -- [ ] **Reconsider storing personnummer at all** — the original `personnummer_hash` (plain SHA-256) is *not* protection: the personnummer keyspace is small enough to brute-force an unsalted hash. Prefer **not storing it**; if a stable identifier is truly required, use a keyed HMAC or authenticated encryption with a server-side secret (`personnummer_enc`), never a bare hash. -- [ ] Passwords hashed with argon2/bcrypt (never SHA) -- [ ] BankID credentials/session never persisted; scraper session discarded after each fetch (see §2.3) +- [ ] **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:** decide the session strategy explicitly — short-lived JWT access token + refresh token, or server-side sessions. Include password reset and email verification flows. None of these were in the original draft. -- **Localization (i18n):** the domain is Swedish (sjukpenningnivå, lägstanivå, dubbeldagar, semester). Default the UI to Swedish with copy externalized for translation; keep dates/numbers locale-aware (sv-SE). -- **Shared-workspace concurrency:** two parents may edit the same calendar at once. Use the `updated_at` column for optimistic concurrency (reject stale writes) and refresh the calendar on focus/poll; real-time sync (WebSocket) is a future enhancement. -- **CORS / rate limiting / secrets:** lock CORS to the frontend origin, rate-limit auth and fetch endpoints, and manage the scraper/HMAC secrets via environment/secret store, not source. +- **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. --- @@ -1418,8 +1211,11 @@ All code must adhere to: - Frontend: `npm run dev` - Backend: `uv run uvicorn app.main:app --reload` -**Production:** -- PostgreSQL database (swap repositories) +**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 @@ -1453,7 +1249,7 @@ The planner never makes API calls at runtime — it uses a committed calendar ca > **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 the workspace `planning_goal` and parent `salary_type`, modeling parent-swap, dubbeldagar, and preschool day reserves along the way. +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**. @@ -1501,23 +1297,26 @@ The separation is critical: the pure allocator is cheap, re-runnable on every wi --- -## Next Steps +## Status of the original open questions -1. **Review this plan** with another model -2. **Refine** any unclear sections -3. **Begin Phase 1** implementation -4. **Maintain test coverage** >= 90% throughout -5. **Weekly sync** on progress - ---- +> **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 Still Open:** +**Questions/Clarifications — resolution status:** -1. Should tips be configurable/editable by users later? -2. Do you want audit logging of data access? -3. Should there be a "preview" of what days would look like before committing? -4. Export functionality (PDF calendar, spreadsheet) in MVP or later? -5. Legal stance on FK scraping + BankID automation — who signs off before public launch, and is the manual-only path acceptable as a v1 if scraping is blocked? -6. Should the app handle special cases (multiples/tvillingtillägg, children born before 2014, single-custody where one parent gets all 480 days) in v1 or later? +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. diff --git a/PLANNING_ENGINE_SPEC.md b/PLANNING_ENGINE_SPEC.md index 30d7830..cd128ab 100644 --- a/PLANNING_ENGINE_SPEC.md +++ b/PLANNING_ENGINE_SPEC.md @@ -2,8 +2,10 @@ > Parts of this document are superseded — search for **SUPERSEDED** for the current behaviour. -**Status:** Draft for review. No code written yet. -**Author:** Senior (design) — implementation to be handed to Sonnet subagents, phase by phase. +**Status:** Shipped. Phases 0–F (see §8) are implemented; this document now +describes actual behavior, with **SUPERSEDED** notes marking design-time +reasoning that later changed. +**Author:** Senior (design) — implementation handed to Sonnet subagents, phase by phase. **Supersedes:** nothing. **Extends:** ARCHITECTURE.md §6 (tips engine) and §6.5 (age-4 projection). --- @@ -20,18 +22,18 @@ That decision is the product. ARCHITECTURE.md §6 already states the principle: > Because the app does not compute kronor, the "money" dimension is expressed qualitatively through > two related levers: **weekly intensity** and **SGI protection**. -> **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely (migration 009). The engine now applies a single policy based solely on `salary_type`. +> **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely (an earlier migration, since squashed). The engine now applies a single policy based solely on `salary_type`. So the engine must not have a fixed spend order. Choosing semester vs föräldrapenning on a given day -*is* the optimisation, and it is driven by `planning_goal` (`max_time` / `max_money` / `balanced`) -crossed with the parent's `salary_type` (`monthly` / `hourly`). +*is* the optimisation — originally driven by `planning_goal` crossed with the parent's `salary_type`, +now (per the note above) by `salary_type` alone (`monthly` / `hourly`). ### Decisions taken (from review) | # | Decision | Consequence | |---|---|---| -| 1 | Spend order is **goal-driven**, never fixed | Strategy selection step in the allocator (§3.3) | -| 2 | Plan for the **couple jointly**, solo-safe, with an **explicit config flag** | New `workspace.planning_mode` (§2.2) | +| 1 | Spend order is **goal-driven**, never fixed | Strategy selection step in the allocator (§3.3, superseded — now `salary_type`-driven, no goal) | +| 2 | Plan for the **couple jointly**, solo-safe | Gated purely on parent count (§2.2, superseded — was `workspace.planning_mode`) | | 3 | **Best-effort + explicit shortfall** when wishes exceed budget | Per-window outcome records a shortfall; never refuses (§3.6) | | 4 | **Multiple children from the start** | Pools and deadlines are keyed `(parent, child)` throughout | @@ -68,10 +70,16 @@ is **zero today — `select count(*) from break_period` returns 0.** If we defer `LeaveWish`. Rejected — the materialised rows are derivable, and persisting derived state invites drift. -> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely (migration 009). +> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely (an earlier migration, since squashed) and never +> replaced — the allocator applies a single policy based on `salary_type` (§3.3), so no per-window +> goal field exists. The entity has since gained fields this section never anticipated: +> `window_mode` (§10.1), `wants_dubbeldag` (§Decision A, see §3.5), `name_message_key` (i18n +> provenance of a preset-derived `name`) and `target_days` (a calendar-drawn period's exact day +> count, overriding `target_weeks * 7`). See `backend/app/entities/leave_wish.py` for the current, +> authoritative field list rather than re-deriving it from this historical snippet. ```python -# backend/app/entities/leave_wish.py +# backend/app/entities/leave_wish.py (as originally proposed — see note above for what changed) @dataclass class LeaveWish: """A family's recurring intent to be home for N weeks inside a yearly window.""" @@ -98,10 +106,6 @@ class LeaveWish: parent_mode: str # 'either' | 'both' | 'specific' parent_personal_data_uuid: UUID | None # required when parent_mode == 'specific' - # Per-window override of workspace planning_goal (§9.4). Nullable, no UI in v1 — - # the field exists now so enabling it later needs no migration. - goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None - created_at: datetime | None = None updated_at: datetime | None = None ``` @@ -120,9 +124,20 @@ above. ### 2.2 `workspace.planning_mode` -Explicitly requested: the planner must *know* whether it is planning for one parent or two, rather -than inferring it from member count (a solo parent may be planning ahead for a partner who has not -joined yet, and a two-member workspace may still want solo plans). +> **SUPERSEDED (2026-09-18).** `planning_mode` was removed entirely (an earlier migration, since squashed). Product +> decision: the original rationale below — that the planner needs an *explicit* flag because +> member count is ambiguous (a solo parent planning ahead for a partner who hasn't joined yet; a +> two-member workspace that still wants solo plans) — did not hold up in practice. In every call +> site that read it, `planning_mode` and parent count moved together: nothing in the product ever +> let a two-parent workspace stay in "solo" mode or a one-parent workspace switch to "couple" mode +> on purpose. The flag was a second source of truth for a fact the workspace's membership already +> states unambiguously. It is gone; **adding a partner to the workspace IS the signal.** +> `AllocationInput` no longer carries a `planning_mode` field — `_Ledger._resolve_parents` +> (§3.3's `parent_swap`) now gates couple wish-assignment purely on `len(self._input.parents) >= +> 2`. The age-4 savable-days cap (§9, `resolve_age4_savable_cap`) was never a function of this flag +> to begin with — it was always purely a function of parent count. + +Original (removed) schema, kept for history: ```sql ALTER TABLE workspace ADD COLUMN planning_mode TEXT NOT NULL DEFAULT 'solo' @@ -131,7 +146,7 @@ ALTER TABLE workspace ADD COLUMN planning_mode TEXT NOT NULL DEFAULT 'solo' ### 2.3 No change to `planned_leave_day` -> **SUPERSEDED (2026-09-15).** `planned_leave_day` gained `wish_id` (migration 008) linking days back to the wish that produced them, and `is_pinned` to mark hand-edited days the allocator should not retype. +> **SUPERSEDED (2026-09-15).** `planned_leave_day` gained `wish_id` (an earlier migration, since squashed) linking days back to the wish that produced them, and `is_pinned` to mark hand-edited days the allocator should not retype. The proposal writes ordinary `planned_leave_day` rows on apply (`day_type` stays `'sjukpenning' | 'lagsta' | 'vacation'`). The existing calendar, dashboard and analytics then work @@ -156,15 +171,20 @@ step that runs before funding, for that subset of wishes. ### 3.1 Input -> **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely (migration 009). +> **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely (an earlier migration, since squashed). +> +> **SUPERSEDED (2026-09-18).** `planning_mode` was removed entirely (an earlier migration, since squashed) — see §2.2. +> Current fields, for reference (`backend/app/services/allocation_service.py` is authoritative): ```python @dataclass(frozen=True) class AllocationInput: today: date # injected, never read from the clock horizon_end: date # default: latest child's 4th birthday - planning_goal: str # 'max_time' | 'max_money' | 'balanced' - planning_mode: str # 'solo' | 'couple' + # Resolved once per run via `resolve_age4_savable_cap(len(parents))` — 96 for a genuine + # single parent, 48 (non-poolable) as soon as the child has 2+ parents. Never re-derived + # inside the pure `_Ledger`. + age4_savable_cap: int children: list[ChildFacts] # id, birth_date, fourth_birthday, preschool_start parents: list[ParentFacts] # uuid, name, salary_type, employment_status pools: dict[tuple[UUID, UUID], DayPool] # (parent, child) → sjukpenning_left, lagsta_left, @@ -173,6 +193,12 @@ class AllocationInput: wishes: list[LeaveWish] existing_planned: list[PlannedLeaveDay] # never double-book holidays: dict[date, str] # per §3.4 + # Workspace DayOverride corrections merged on top of `holidays` (see `is_free_day`). + day_overrides: dict[date, bool] + # Full, unfiltered snapshot of every persisted `planned_leave_day` row — lets a flexible + # wish re-find its own prior placement (matched by `wish_id`) even when a parent + # reassignment currently hides it from `existing_planned` (§10.3). + all_existing_planned: list[PlannedLeaveDay] | None ``` ### 3.2 Expand wishes into concrete windows @@ -196,7 +222,7 @@ year-wrap/vacation-straddle rules above without any change. ### 3.3 Choose a strategy per window — dominance first, goal only for ties -> **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely (migration 009). The allocator now applies a single policy based solely on `salary_type`, skipping the goal-driven tier-3 choice entirely. +> **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely (an earlier migration, since squashed). The allocator now applies a single policy based solely on `salary_type`, skipping the goal-driven tier-3 choice entirely. A naive design makes `planning_goal` a hard switch between "spend semester" and "spend FP". That is wrong, and the Jullov case shows why: @@ -225,31 +251,36 @@ salary-type independent, so red-day-cluster vacation is favourable for hourly pa bracketing a bare Sat/Sun with Fri+Mon vacation buys nothing — but it does **not** justify suppressing red-day-cluster vacation. -> **SUPERSEDED (2026-09-15).** Tier 3 with goal-dependent choices was removed entirely. All tier-3 decisions now depend solely on `salary_type`. +> **SUPERSEDED (2026-09-15).** Tier 3 no longer consults a goal at all — there is a single policy, +> branching only on `salary_type`, that maximises value extracted from FP + vacation combined. The +> table below has been replaced to describe it; see `_assign_tier3` in `allocation_service.py`. -**Tier 3 — goal-dependent interior.** Ordinary working days with no red-day adjacency, where semester -vs FP is a real tradeoff. Here, and only here, consult the resolved goal -(`wish.goal_override or planning_goal`): +**Tier 3 — salary-type interior.** Ordinary working days with no red-day adjacency, where semester +vs FP is a real tradeoff: -| Resolved goal | Interior choice | Source | +| `salary_type` | Interior choice | Why | |---|---|---| -| `max_money` + monthly | semester (keeps full pay) | §6.1 rules #2, #3 | -| `max_money` + hourly | FP — wrapping buys nothing | §6.1, §6.3 | -| `max_time`, child < 12 months | **no FP at all** — SGI is protected anyway | §6.1 `skip_fp_first_12_months` | -| `max_time` otherwise | FP lägstanivå / lower weekly intensity to stretch | §6.1 `stretch_time_at_home` | -| `balanced` | alternate: semester early in the window, FP for the remainder | — | +| `monthly` | vacation first; FP (sjukpenning preferred, then lägstanivå) fills what vacation can't | A monthly-salaried parent loses nothing by spending a vacation day, so spending it first preserves FP for later. | +| `hourly` | FP first (sjukpenning preferred, then lägstanivå); vacation only backstops what FP can't cover | An hourly parent loses pay on a vacation day (no salary to "keep" — §6.1/§6.3), so FP goes first. | -**`parent_swap` composes on top of all three tiers.** When `planning_mode == 'couple'` and -`target_weeks >= 2`, it first decides *who* is home in each half of the window; each parent's days are -then resolved through the tiers above. This remains the single biggest lever in the app — per §6.1, -two halves of length L yield ≈2×L weeks of family time (3+3 weeks ≈ 6 weeks together). +If neither resource is available (pool/budget exhausted) the day is left unfunded and counts toward +the window's shortfall (§3.6). -> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely (migration 009). This test is no longer applicable. +**`parent_swap` composes on top of all three tiers.** When the workspace has **2+ parents** (see +§2.2 — superseded `planning_mode`) and `target_weeks >= 2`, it first decides *who* is home in each +half of the window; each parent's days are then resolved through the tiers above. This remains the +single biggest lever in the app — per §6.1, two halves of length L yield ≈2×L weeks of family time +(3+3 weeks ≈ 6 weeks together). -**Corollary worth testing:** because tiers 1–2 are goal-independent, setting `goal_override = -'max_money'` on a red-day-dense window like Jullov should produce **the same allocation** as -`max_time` would. The override is a UI convenience for the user's mental model, not a semantic -necessity — and the engine should be able to prove it (test #14b, §5). +> **SUPERSEDED (2026-09-15).** `goal_override` and the whole goal-driven framing were removed +> entirely — there is no longer a `max_money`/`max_time`/`balanced` choice to make invariant. The +> corollary below is kept only as a historical note; test #14b no longer exists in the current suite. + +**Corollary (historical):** the original design argued that, because tiers 1–2 were +goal-independent, `goal_override = 'max_money'` on a red-day-dense window like Jullov should +produce the same allocation as `max_time` would — proof that the goal only mattered for the +genuinely ambiguous tier-3 interior. With tier 3 now resolved purely by `salary_type` (no goal at +all), this invariant is moot: there is nothing left for a goal to disagree with. **Explanation copy must state both benefits** when a tier-2 play fires, or the user will think the engine optimised for the wrong thing: @@ -289,8 +320,21 @@ Constraints — all hard unless marked: counter, and the UI must not show one. *(This is `todo.txt` item #5, and the engine depends on it being modelled correctly.)* - **Dubbeldagar**: only within the child's first 15 months, ≤60 total, consumes **both** parents' - pools for that date. Only reachable via `parent_mode == 'both'`. -> **SUPERSEDED (2026-09-15).** The age-4 savable cap was incorrectly applied as 96 per parent. It is now 48 per parent in couple mode with two parents, and 96 otherwise. + pools for that date. Reachable only inside a `parent_mode == 'both'` window, and even there + **opt-in, never automatic** — see the co-leave correction below. + +> **SUPERSEDED (2026-09-18, §Decision A/C "co-leave" correction).** A `parent_mode == 'both'` +> window does **not** default to dubbeldagar. The default is **co-leave**: both parents home on +> the same date, funded as one PL day (`sjukpenning`/`lagsta`, to whichever parent has more FP +> left) plus one vacation day (the other parent) — `strategy = 'co_leave'`. Dubbeldagar +> (`strategy = 'dubbeldag'`) fire only when the wish's `wants_dubbeldag` flag is explicitly set +> **and** the date is legally eligible (inside the child's first 15 months, quota +> left, both parents hold FP). A date neither co-leave nor dubbeldagar can cover falls back to +> `strategy = 'swap'` — the same sequential per-parent-half funding `parent_swap` uses. See +> `allocation_service.py`'s `_fund_both` and the `WindowOutcome.strategy` / +> `requested_strategy` / `strategy_degraded` / `dubbeldagar_legally_available` fields (§10.4). + +> **SUPERSEDED (2026-09-15).** The age-4 savable cap was incorrectly applied as 96 per parent. It is now 48 per parent when the child has two (or more) parents, and 96 for a genuine single parent. - **Age-4 is an objective *before* B4 and a hard cap *after* it** (§9.1). Before the 4th birthday, days above 96 that remain will be *lost*, so the allocator mildly **prefers** spending before B4 and @@ -347,16 +391,29 @@ DELETE /api/leave-wishes/{id} POST /api/plan/propose # → PlanProposal. Read-only, persists nothing. Safe to spam on edit. POST /api/plan/apply # body: the accepted days → writes planned_leave_day rows +POST /api/plan/sync # the direct-manipulation calendar's one call (see note below) ``` `apply` must be idempotent per `(workspace, child, parent, date)` and must reject days that collide with existing planned days unless `replace: true` is passed. Routers stay thin; all logic in services. +> **Note (2026-09-18).** `/api/plan/sync` is a newer, actively-changing endpoint — the direct-edit +> calendar's single call, distinct from the propose→apply pair above. Its request/response shape is +> not documented here yet; a follow-up pass will cover it once that work lands. `propose` today +> backs a read-only preview surfaced on the Översikt tab (`PlanFold.tsx`/`usePlanProposal`), not a +> calendar propose-then-apply flow — `apply` itself has no frontend caller at the time of writing. + --- ## 5. Test matrix (the acceptance criteria) -> **SUPERSEDED (2026-09-15).** Several test cases are outdated: (1) Tests 1, 3–4 assume `planning_goal` is operative (removed migration 009); (2) Test 14 refers to `goal_override` (removed); (3) Tests 8 and 12 use the incorrect 96-day-per-parent cap (now 48 per parent in couple mode with two parents, 96 otherwise). +> **SUPERSEDED (2026-09-15, then 2026-09-18).** Several test cases are outdated: (1) Tests 1, 3–4 +> assume `planning_goal` is operative (removed); (2) Test 14 refers to `goal_override` +> (removed); (3) Tests 8 and 12 use the incorrect 96-day-per-parent cap (now 48 per parent when the +> child has two parents, 96 otherwise); (4) Test 10 and the "test #10" cross-reference in test 22 +> describe dubbeldag-outside-15-months falling back to "sequential days" — the actual fallback +> chain for a `parent_mode == 'both'` window is co-leave first, then a sequential swap only if +> co-leave itself can't cover the date (§Decision A/C, see §3.5). Against the **pure** `Allocator`, with a frozen `today`. Target ≥90% on new backend code. @@ -396,6 +453,18 @@ Rows 15+ target flexible ("när det passar bäst") wishes — see §10 for the m ## 6. Frontend +> **SUPERSEDED (2026-09-18).** The standalone `/plan` page below was never built. Wishes are +> authored directly on the **Kalender** tab instead: dragging a range opens `PeriodPopover.tsx` +> (not `WishEditor.tsx`), which edits a wish's name/dates/`parentMode`/recurrence in place — there +> is no separate multi-year timeline page or "Föreslå plan"/"Använd plan" two-step. A season-preset +> rail (`PresetRail.tsx`) helps place common windows. The `POST /api/plan/propose` preview instead +> backs a shortfall/surplus fold on the **Översikt** (Dashboard) tab (`PlanFold.tsx`); direct +> calendar edits go through `POST /api/plan/sync` (see §4 note — HOLD, not detailed here). Data +> layer: `useLeaveWishes`, `usePlanProposal`, and `usePlanSync` (all in `frontend/src/hooks/`) — +> components still never call the API client directly. The description below is kept for the +> original design rationale (recurrence/priority/parent fields, per-window explanation, i18n via +> `message_key`+`data`), which mostly still holds even though the page shape changed. + New page `src/pages/Plan.tsx`, route `/plan`, nav entry "Plan". - **Wishes editor** — `BreakPeriodsInput` evolves into `LeaveWishesInput` (recurrence, priority, @@ -434,7 +503,7 @@ ARCHITECTURE.md — and can be dropped independently. | Phase | Scope | Gate | |---|---|---| -| **0** | `todo.txt` #1, #2, #5 + `workspace.planning_mode` | ruff, pytest, tsc, eslint, vitest | +| **0** | `todo.txt` #1, #2, #5 + `workspace.planning_mode` (the flag itself was later dropped entirely — see §2.2) | ruff, pytest, tsc, eslint, vitest | | **A** | `LeaveWish` entity, migration, repo, service, CRUD router, tests | backend suite | | **B** | `AllocationInput` loader + **pure `Allocator`** + `/plan/propose` + the §5 matrix | ≥90% on new code | | **C** | `/plan/apply` + idempotency/collision rules | backend suite | @@ -452,7 +521,8 @@ test matrix lives. Phases A–E each end with `graphify update .` per CLAUDE.md. 1. **Horizon runs past age 4.** Wishes may extend beyond the 4th birthday and the app must not block that. Consequence — the FP budget becomes **time-dependent**, which the allocator must model: - > **SUPERSEDED (2026-09-15).** The 96-day cap is now 48 per parent in couple mode with two parents, 96 otherwise. + > **SUPERSEDED (2026-09-15).** The 96-day cap is now 48 per parent when the child has two (or + > more) parents, 96 for a genuine single parent. ``` FP available for a day d: d < fourth_birthday → full remaining pool @@ -472,11 +542,10 @@ test matrix lives. Phases A–E each end with `graphify update .` per CLAUDE.md. reserve is **user-configurable** (`preschool_planning_days_per_year`, default 6 — preschools differ), so it needs a per-child settings field, not just the config default. -> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely (migration 009). - -4. **Wish-level goal override: yes, as a nullable field now, no UI in v1.** `LeaveWish.goal_override: - str | None`; the allocator resolves `goal = wish.goal_override or input.planning_goal`. Costs one - field and one test, and avoids a second migration when the UI arrives. +4. **Wish-level goal override: reversed.** The original decision was to add `LeaveWish.goal_override: + str | None` so the allocator could resolve `goal = wish.goal_override or input.planning_goal`. + `goal_override` was instead removed entirely, along with `planning_goal` itself (§3.3) — there is + no per-window (or per-workspace) goal left to override. ### 9.2 Surplus advice — the mirror of a shortfall (added after reviewing demo output) @@ -541,13 +610,14 @@ open. One new field on `LeaveWish`, added to the trailing defaulted group so every existing construction site (fixtures, the repo, the service) keeps compiling unchanged: -> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely (migration 009). +> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely before this field landed, so it +> never coexisted with `window_mode` in the real entity — the snippet below has been corrected to +> drop it. ```python @dataclass class LeaveWish: ... - goal_override: str | None = None # NEW. 'fixed' (default) = window_start_*/window_end_* are the exact placement, as today. # 'flexible' = the SAME four fields instead describe the BOUNDING RANGE the engine searches @@ -633,27 +703,31 @@ one collision key are unchanged. 6. **Cost each surviving candidate** as the count of *spendable* days it contains: `cost(s) = sum(1 for d in [s, s+length_days-1] if not is_free_day(d, holidays, day_overrides))`, reusing the exact `is_free_day` helper from §3.4 (so workspace day overrides are already - respected). This is deliberately calendar-only — it does not run the goal-dependent tier-3 logic - or simulate which resource would be spent. - > **Why a calendar-only cost is exactly "fewest paid days".** For a fixed-length window, every - > spendable day consumes exactly one resource unit under best-effort funding — vacation, - > sjukpenning or lagsta, chosen by tiers 1–3, but always exactly one unit *some* way. So the - > candidate with the fewest spendable days *is* the candidate needing the fewest paid days, - > for every planning goal and every `parent_mode` — including `dubbeldag` mode, where a spendable - > day costs one unit from *each* parent's pool: that doubles the resource cost uniformly across - > every candidate, so it never changes which candidate is cheapest. This is the same principle as - > §3.3's tiers 1–2 being goal-independent "dominant plays" — window *choice* is dominant too, and - > the goal only ever governs *which* resource pays for a spendable day, never *how many* days - > must pay. + respected). This calendar-only figure is the tie-break, not the primary ranking — see the + §Decision C correction below. + + > **SUPERSEDED (2026-09-18, §Decision C "budget-aware candidate ranking").** The original + > reasoning here — that a calendar-only cost is *always* exactly "fewest paid days", so goal and + > affordability never need to enter candidate selection — held only as long as every candidate's + > resolved parent(s) could actually afford every spendable day. In practice a candidate that is + > cheap by the calendar can still leave half its days unfunded (vacation/FP already exhausted + > elsewhere), which is worse than a slightly costlier candidate the parent(s) can fully pay for. + > Candidates are now ranked by **`(shortfall, calendar_cost, start, wish.id)`** — + > `_candidate_shortfall` simulates the real `_fund_solo`/`_fund_swap`/`_fund_both` funding logic + > against a scratch copy of the live pool/vacation state (restored afterwards; `propose()` stays + > read-only and deterministic, §5 test #11) and counts what could **not** be funded; only when two + > candidates tie on shortfall does the calendar-only `cost` from step 6 break the tie. See + > `allocation_service.py`'s `_candidate_shortfall`, `resolve_flexible_window` and + > `_reuse_already_funded_window`. 7. **If no candidate survives step 5's collision filter**, this wish-year is **unplaced** — §10.4. 8. **Otherwise**, pick the winner deterministically: - `min(candidates, key=lambda c: (cost(c.start), c.start, wish.id.int))`. Ties are broken first by - earliest start date (fills the bound from its beginning — the same chronological preference - `Allocator.propose()` already uses to order windows), then by the wish's UUID integer value — - **never** by set/dict iteration order and never by anything clock-dependent, per §6's purity - requirement. This is the identical tie-break shape already used at - `windows.sort(key=lambda w: (w.wish.priority, w.start, w.wish.id.int))`; §10 does not invent a new - convention, it reuses this one. + `min(candidates, key=lambda c: (shortfall(c), cost(c.start), c.start, wish.id.int))` (§Decision C + above). Ties on shortfall are broken by calendar cost, then by earliest start date (fills the + bound from its beginning — the same chronological preference `Allocator.propose()` already uses + to order windows), then by the wish's UUID integer value — **never** by set/dict iteration order + and never by anything clock-dependent, per §6's purity requirement. This extends the tie-break + shape already used at `windows.sort(key=lambda w: (w.wish.priority, w.start, w.wish.id.int))`; + §10 does not invent a new convention, it reuses and extends this one. 9. The winning candidate becomes an ordinary `_WishWindow` (its `anchor_year`, resolved `start`/`end`) and is funded through the unchanged `ledger.fund_window()` — tiers 1–3, the vacation/FP constraints, the B4 clamp and the dubbeldagar 15-month fallback all apply exactly as they do to a @@ -666,9 +740,11 @@ one collision key are unchanged. window's. - **B4 cap (§3.5, §9.1).** Unaffected. `_maybe_clamp_age4` fires off the *funded* window's start date, regardless of how that window was chosen. A flexible window that happens to straddle B4 is clamped - exactly as a fixed one would be. Window selection does not bias toward or away from B4 — the - cost function is calendar-only (§10.2 step 6) and B4-blind by design, so it does not need a - redundant preference on top of the clamp that already exists. + exactly as a fixed one would be. Window selection does not carry a separate, explicit B4 + preference — but it is no longer B4-blind either: candidate ranking is budget-aware + (§10.2 step 6/§Decision C), and `_candidate_shortfall`'s funding simulation runs through the same + B4 clamp every real window does, so a candidate that would run into the B4 wall is already + disfavored via its simulated shortfall, not just via the clamp applied after the fact. - **Dubbeldagar / 15-month window.** No special-casing needed. `parent_mode == 'both'` is resolved identically for flexible wishes (§10.2 step 3, same `_resolve_parents` call); if the *chosen* window happens to fall outside the child's first 15 months, `_fund_both`'s existing fallback to @@ -676,14 +752,23 @@ one collision key are unchanged. know about the 15-month rule at all. - **Reserved days.** No interaction — reserved days are a transfer constraint on `parent_swap` composition (§3.5), orthogonal to which window was picked. -> **SUPERSEDED (2026-09-15).** `planned_leave_day` gained `wish_id` (migration 008) linking days back to the wish that produced them, and `is_pinned` to mark hand-edited days the allocator should not retype. +> **SUPERSEDED (2026-09-15, then 2026-09-18).** `planned_leave_day` gained `wish_id` (an earlier migration, since squashed) +> linking days back to the wish that produced them, and `is_pinned` to mark hand-edited days the +> allocator should not retype. The reasoning below — that no frozen linkage is needed because the +> propose→apply preview is the safeguard — did not survive contact with parent reassignment: a +> flexible wish's prior placement needs to be found again even when it is currently *hidden* from +> `existing_planned` (a reassigned parent has different occupied dates) or funded under a parent the +> wish no longer resolves to. `wish_id` is now the identity key that makes that possible. -- **Applied flexible wishes participate in re-search.** On a subsequent `propose()` call (after the - family edits wishes), a previously-applied flexible wish is re-searched from scratch within its bound - and may land in a different window if the occupied set has changed (other wishes added, edited or - removed). This is safe because the propose→apply flow always previews the new outcome before the - family confirms (§1), so any movement is visible. Thus `planned_leave_day` needs no `wish_id` column - — no frozen linkage is needed, because the safeguard is the preview, not schema-level persistence. +- **Applied flexible wishes participate in re-search, matched by `wish_id`.** On a subsequent + `propose()` call (after the family edits wishes, or reassigns a wish's parent), a previously-applied + flexible wish's own prior placement is looked up via `AllocationInput.all_existing_planned` — + the full, unfiltered snapshot of every persisted `planned_leave_day` row, keyed by `wish_id` rather + than by parent — so `_reuse_already_funded_window` can prefer reusing that placement (via + `_persisted_by_wish`/`_persisted_dates_for_wish`) even when it is invisible to the narrower + `existing_planned` collision set. A wish may still land in a different window if its bound or the + occupied set changed enough to make reuse impossible; the propose→apply preview (§1) keeps that + movement visible before it is confirmed. - **Preschool planning-day reserve (§9.3).** No interaction — the reserve is deducted off the top before *any* wish (fixed or flexible) is funded, per wish-year window or not. - **Surplus advice (§9.2).** No change to the mechanics. A fully-funded flexible wish participates in @@ -718,18 +803,24 @@ long enough, but every full-length candidate collides with a date another wish a `lower_priority_wish` both still make sense (neither needs a *specific* winning window). Omit `shift_window_into_free_days` — there is no single window edge left to shift. -No new field is added to `WindowOutcome`, `ProposedDay` or `PlanProposal` for any of this — the -`strategy` field is already typed as a bare `str` on both sides (`allocation_service.py`'s dataclass -and `frontend/src/types/entities.ts:177`, which already documents it as intentionally open-ended), so -adding a literal value is a copy/behavior change, not a schema change. +> **SUPERSEDED (2026-09-18).** The claim that no new field is needed did not survive the §Decision +> A/C co-leave correction (§3.5): once `strategy` could legitimately *degrade* from what a +> `parent_mode == 'both'` wish asked for (dubbeldag → co-leave → swap) down to something worse, a +> bare after-the-fact `strategy` string could no longer say whether that happened. `WindowOutcome` +> gained three fields to make that visible: `requested_strategy` (what `parent_mode` + +> `wants_dubbeldag` asked for), `strategy_degraded` (`bool`, true whenever `strategy != +> requested_strategy`), and `dubbeldagar_legally_available` (whether dubbeldagar were even a legal +> option for this window, independent of whether the user opted in — the frontend uses this to +> decide whether to show the dubbeldag checkbox at all). `strategy`/`requested_strategy` remain a +> bare `str` as originally described — adding a literal value (`'co_leave'`, `'swap'`) is still a +> copy/behavior change, not a schema change; the three new fields are the actual schema change. ### 10.5 Persistence -`LeaveWish` gains exactly one column. Hand-written Alembic revision, following migration 002's -pattern (`backend/migrations/versions/002_planning_mode_and_preschool_days.py`) exactly: +`LeaveWish` gains exactly one column. Hand-written Alembic revision, following the pattern already +established by the existing migrations in `backend/migrations/versions/` exactly: ```sql --- 006_add_leave_wish_window_mode.py ALTER TABLE leave_wish ADD COLUMN window_mode TEXT NOT NULL DEFAULT 'fixed' CHECK (window_mode IN ('fixed','flexible')); ``` @@ -739,8 +830,8 @@ with the matching `DROP COLUMN` downgrade. > **Do not forget `_FULL_SCHEMA`.** `backend/app/db/connection.py`'s `_FULL_SCHEMA` is what test > fixtures apply directly (`ensure_schema`) — it does not run Alembic. Add the same column + CHECK to > the `leave_wish` table definition there, or every test silently runs against a schema one migration -> behind reality (NextSteps260913.md §4.2; migration 002 is the worked example for keeping the two in -> sync). +> behind reality — keep the two in sync by hand; `tests/integration/test_schema_parity.py` asserts +> they agree. Also needed, all mechanical and all outside `backend/app/services/`: @@ -756,19 +847,22 @@ feature is deliberately just the one new column. ### 10.6 i18n -Every new user-facing string this feature produces — the "engine picked this window" explanation, the -unplaced-wish explanation, any new remedy copy — must use the `message_key` + `data` pattern -`tips_service.py`'s `Tip` dataclass already establishes (`message_key: str`, `data: dict`), which -NextSteps260913.md §3.5 is rolling out to `outcome.explanation`, `remedy.description` and -`warning.message` concurrently with this work. **No hardcoded Swedish strings** in anything new here -— that would recreate exactly the leak §3.5 exists to fix, on day one of a brand-new feature. The -exact key names/namespace are §3.5's call (it is landing in `backend/app/services/` concurrently); -this section only fixes the *pattern*, not the vocabulary. +> **SUPERSEDED (2026-09-18).** This section originally described the `message_key`+`data` rollout +> as in-progress, citing a `NextSteps260913.md` handoff doc that is not (and never was) checked into +> this repo. The rollout is done: every UI-facing piece of text the allocator produces — +> `WindowOutcome.data`/`message_key`, `ShortfallRemedy`, `SurplusAdvice`, `PlanWarning` — follows the +> pattern `tips_service.py`'s `Tip` dataclass established first. The frontend resolves these opaque +> keys through `src/i18n/` (`sv`/`en` namespaces, `LanguageContext`) via per-component lookup tables +> (e.g. `PlanResults.tsx`). See ARCHITECTURE.md §7.2 for the full picture. -Frontend copy needing new keys (final naming left to whoever implements `WishEditor.tsx`, §10.7): -the fixed/flexible toggle's two labels, the bounding-range field label (to make clear it is a search -range, not the final dates, once "flexible" is selected), and any season quick-picks added to the -preset list. +Every user-facing string this feature produces — the "engine picked this window" explanation, the +unplaced-wish explanation, remedy copy — uses the `message_key` + `data` pattern. **No hardcoded +Swedish strings** anywhere in the allocator's output. + +Frontend copy needing new keys (final naming left to whoever implements the fixed/flexible UI, +§10.7): the fixed/flexible toggle's two labels, the bounding-range field label (to make clear it is +a search range, not the final dates, once "flexible" is selected), and any season quick-picks added +to the preset list. ### 10.7 UI: `WishEditor.tsx` diff --git a/README.md b/README.md index 301789a..91e9b5a 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,12 @@ A planner for Swedish parental leave (föräldraledighet) — solo or with a partner — that folds in vacation days and optimizes leave over time so that as few days as possible are lost at the child's 4th birthday, after which -only 96 days may be saved (48 per parent, in couple mode). +only 96 days may be saved (48 per parent, non-poolable, as soon as the child +has two or more parents). Full-stack monorepo: TypeScript/React/Vite frontend, Python/FastAPI backend, -SQLite by default (Postgres as an optional extra). +SQLite by default. Postgres is a partial stub, not a usable swap yet — see +`backend/README.md`. ## Quick start @@ -52,9 +54,11 @@ Three tabs: per-parent bars against each parent's savable cap, a year-by-year timeline, and shortfall/surplus detail below the fold. - **Kalender** — where leave is authored. Drag a range to create a period, - click a band to edit it, click a day to change how it's funded or mark it - worked/off. A season-preset rail helps place common periods (summer, - Christmas, Easter). + click a band to edit it, click a day to change which resource funds it + (sjukpenning/lägstanivå/vacation). A season-preset rail helps place common + periods (summer, Christmas, Easter). Day overrides (marking a specific date + as a working day or a free day, overriding the calendar) live in + Inställningar, not on the calendar itself. - **Inställningar**. **The calendar is the plan.** There's no separate propose-then-apply step: @@ -63,8 +67,9 @@ an undo. A `LeaveWish` is a recurring intent (e.g. "summer", "Christmas"); the allocator decides which resource — parental days, vacation, unpaid — funds each day, without retyping days a user has pinned by hand. -First run walks a wizard: add the child, choose solo or couple planning -(optionally add a partner), then set balances — fetch from +First run walks a wizard: add the child, optionally add a partner (planning +solo vs. together is derived from whether a partner has been added — there +is no separate solo/couple choice), then set balances — fetch from Försäkringskassan via BankID, enter manually, or skip (figures are then marked as estimates). @@ -75,7 +80,7 @@ backend/app/ entities/ persistence-layer row shapes models/ Pydantic v2 API schemas repositories/ abstract repository interfaces - repositories_impl/ sqlite/ (default) and postgres/ (optional) impls + repositories_impl/ sqlite/ (default, complete) and postgres/ (partial — one entity only) services/ business logic — planning, tips, analytics, auth scrapers/ Försäkringskassan scraper (Playwright) routers/ thin FastAPI route handlers @@ -113,11 +118,10 @@ npm run lint # ESLint, zero warnings allowed npm run coverage # Vitest, thresholds: lines 96% / functions 91% / branches 92% / statements 96% ``` -Current numbers: backend 587 tests passing at ~87% coverage (gate: 80%); -frontend 949 tests across 62 files passing at ~99% lines/statements, ~93% -functions, ~95% branches (gates: 96/91/92/96 — see -`frontend/vitest.config.ts`). Gates are a regression ratchet, not a target — -they sit a couple of points below the measured baseline. +Both suites pass comfortably above their gates (backend ~84% vs. an 80% +floor; frontend above 96/91/92/96 — see `frontend/vitest.config.ts`). Gates +are a regression ratchet, not a target; run the commands above for current +numbers rather than trusting a hardcoded count here, which drifts fast. CI (`.github/workflows/ci.yml`) runs all of the above, plus a Playwright E2E suite that reports but does not yet block merges. diff --git a/backend/.env.example b/backend/.env.example index d5ab665..f0f56b3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,9 @@ APP_SCRAPER_ENABLED=true # set to actually enable saving. # APP_SCRAPER_DEBUG_ENABLED=true # APP_SCRAPER_DEBUG_DIR=./debug/scraper +# Also dump the same artifacts after a SUCCESSFUL extraction (not just on +# error) — useful for confirming FK's real DOM. Same warning applies. +# APP_SCRAPER_DEBUG_DUMP_ON_SUCCESS=true # Database (default: SQLite in repo root) # APP_SQLITE_PATH=./foraldraledig.db diff --git a/backend/README.md b/backend/README.md index 9572ec8..b529f2b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,8 +1,12 @@ # Backend — Föräldraledigplannerare FastAPI + Python 3.14, packages managed with `uv`. Layered architecture -(entities → repositories → services → routers) with dependency injection so the -database backend (SQLite now, Postgres later) is swappable. +(entities → repositories → services → routers) with dependency injection. +**Postgres is a partial stub, not a working swap today:** only +`repositories_impl/postgres/vacation_days_repository.py` exists; every other +`app/dependencies.py` repository factory raises `ValueError` for any +`database_type` other than `"sqlite"`. SQLite is the only usable backend +until the remaining Postgres repositories land. ## Setup @@ -13,16 +17,55 @@ uv run pytest # run tests with coverage uv run ruff check . # lint ``` +`uv run pytest` alone does not enforce a coverage floor (`pyproject.toml`'s +`addopts` only requests a report). CI additionally passes +`--cov-fail-under=80` (see the root `CLAUDE.md`) — a locally green run can +still fail in CI if coverage dropped. + ## Configuration -Key env vars (prefix `APP_`, see `.env.example`): +Env vars (prefix `APP_`, nested via `__`; see `.env.example`). `Settings` +lives in `app/config.py` — that file's module docstring mentions a +`HMAC_PEPPER` setting; **no such setting exists**, so do not look for it here +or set it expecting an effect. - `APP_ENV` — `development` (default) | `test` | `production`. In production, `Settings` refuses to start unless `APP_JWT_SECRET` is a random value of at least 32 characters (the shipped dev default is rejected). Production also disables `/docs`, `/redoc`, and `/openapi.json`. +- `APP_DATABASE_TYPE` — `sqlite` (default) | `postgres` (not usable yet — see above). +- `APP_SQLITE_PATH` — default `./foraldraledig.db`. +- `APP_CORS_ORIGINS` — JSON list; default `["http://localhost:5173"]`. - `APP_JWT_SECRET` — required to be a strong, random secret in production. +- `APP_JWT_ALGORITHM` — default `HS256`. +- `APP_JWT_ACCESS_TOKEN_EXPIRE_MINUTES` — default `15`. +- `APP_JWT_REFRESH_TOKEN_EXPIRE_DAYS` — default `7`. - `APP_LOG_LEVEL` — default `INFO`. +- `APP_SCRAPER_ENABLED` — **defaults to `true`.** Legal/ToS review of FK scraping + BankID + automation completed 2026-06; the scraper is cleared for use. Set to `false` in any + environment without Playwright's Chromium installed (`uv run playwright install chromium`). +- `APP_SCRAPER_HEADLESS` — default `true`. +- `APP_SCRAPER_SESSION_TTL_SECONDS` — default `180`; auto-cancels a stalled scraper session. +- `APP_SCRAPER_DEBUG_ENABLED` — default `false`. **When enabled, writes personal data to disk** + (an HTML dump + full-page screenshot of the logged-in FK page — name, balances, payout + amounts). Requires `APP_SCRAPER_DEBUG_DIR` to also be set. Local debugging only; never enable + in a shared or production environment. +- `APP_SCRAPER_DEBUG_DIR` — where debug artifacts are written when the above is enabled. +- `APP_SCRAPER_DEBUG_DUMP_ON_SUCCESS` — default `false`. Also writes the same debug artifacts + (HTML + full-page screenshot) after a **successful** extraction, not just on error — a capture + aid for confirming FK's real DOM (e.g. the family panel structure `app/scrapers/fk_scraper.py` + currently assumes). Same personal-data warning as `APP_SCRAPER_DEBUG_ENABLED` applies; only + takes effect when both `APP_SCRAPER_DEBUG_ENABLED` and `APP_SCRAPER_DEBUG_DIR` are also set. +- `APP_SCRAPER_RESULT_RETENTION_SECONDS` — default `300`; how long a finished scraper session's + result stays in memory before being purged. +- `APP_RULES__*` — the Swedish parental-leave rule constants (`LeaveRules`, nested under `rules`), + overridable per field, e.g. `APP_RULES__MAX_SAVABLE_DAYS_AT_AGE_4=96`. Defaults: 390 + sjukpenning days, 90 lägstanivå days, 90 reserved days/parent, 60 max dubbeldagar over the + first 15 months, 96 max savable at age 4 (48 per parent when the child has two or more + parents — see `resolve_age4_savable_cap`), age-12 hard expiry, 6 preschool planning days/year, + 25 statutory vacation days/year, 20 minimum vacation days/year that must be taken. These are + common-case figures only — children born before 2014, multiples, and single custody differ and + are not modeled. ## Layout diff --git a/backend/app/config.py b/backend/app/config.py index 4eb0c18..bc603e0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -81,10 +81,11 @@ class LeaveRules(BaseModel): @property def max_savable_days_per_parent_at_age_4(self) -> int: - """Age-4 ceiling — per PARENT. `max_savable_days_at_age_4` (96) is a - per-child pool; when a child has two parents, each parent's own - savable balance is capped at half of that (48), not 96 each (which - would let a two-parent family save 192 combined). + """Age-4 ceiling — per PARENT, non-poolable. `max_savable_days_at_age_4` + (96) is a per-child pool; as soon as a child has two parents, each + parent's own savable balance is capped at half of that (48), not 96 + each (which would let a two-parent family save 192 combined). This + applies purely based on how many parents the child has. `app.utils.tip_predicates.resolve_age4_savable_cap` is the single place that decides which of the two applies. """ @@ -132,6 +133,13 @@ class Settings(BaseSettings): # a deliberate opt-in for local debugging only, never for production. scraper_debug_dir: str | None = None scraper_debug_enabled: bool = False + # Also dump debug artifacts (HTML + full-page screenshot) after a + # *successful* extraction, not just on error — a capture aid for + # confirming FK's real DOM (e.g. the family panel structure assumed by + # `app.scrapers.fk_scraper._SEL.FAMILY*`). Same personal-data warning as + # above applies; only takes effect when both `scraper_debug_enabled` and + # `scraper_debug_dir` are also set. + scraper_debug_dump_on_success: bool = False # How long a terminal (done/error/cancelled) session's QR/result stays in # RAM before `_ttl_watchdog`/`poll` purge it from `_sessions`. scraper_result_retention_seconds: int = 300 diff --git a/backend/app/db/connection.py b/backend/app/db/connection.py index c389bfa..5b68e1c 100644 --- a/backend/app/db/connection.py +++ b/backend/app/db/connection.py @@ -25,8 +25,6 @@ _FULL_SCHEMA = """ CREATE TABLE IF NOT EXISTS workspace ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - planning_mode TEXT NOT NULL DEFAULT 'solo' - CHECK (planning_mode IN ('solo','couple')), created_at TIMESTAMP NOT NULL ); @@ -107,6 +105,7 @@ CREATE TABLE IF NOT EXISTS leave_wish ( parent_personal_data_uuid TEXT, window_mode TEXT NOT NULL DEFAULT 'fixed' CHECK (window_mode IN ('fixed','flexible')), + wants_dubbeldag INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, @@ -119,7 +118,7 @@ CREATE TABLE IF NOT EXISTS parental_leave_fetch ( personal_data_uuid TEXT NOT NULL, workspace_id TEXT NOT NULL, child_id TEXT NOT NULL, - source TEXT NOT NULL CHECK (source IN ('scraped','manual')), + source TEXT NOT NULL CHECK (source IN ('scraped','manual','deduced')), fetched_at TIMESTAMP NOT NULL, total_days INTEGER NOT NULL, days_used INTEGER NOT NULL, @@ -191,8 +190,8 @@ CREATE TABLE IF NOT EXISTS tip_dismissal ( FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); --- Indexes (migration 011). Mirrored verbatim (same columns, same order, no --- partial indexes) from migrations/versions/011_add_indexes.py so +-- Indexes. Kept in step (same columns, same order, no partial indexes) with +-- migrations/versions/001_initial_schema.py so -- tests/integration/test_schema_parity.py, which compares index sets between -- the Alembic chain and this hand-maintained DDL, stays green. See the -- backend-perf audit §4 for what each index serves. diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index b2330d8..79e5c6f 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -257,7 +257,6 @@ def get_tips_service( pd_repo: PersonalDataRepositoryDep, vacation_repo: VacationDaysRepositoryDep, leave_wish_repo: LeaveWishRepositoryDep, - ws_repo: WorkspaceRepositoryDep, dismissal_repo: TipDismissalRepositoryDep, ) -> TipsService: return TipsService( @@ -267,7 +266,6 @@ def get_tips_service( pd_repo, vacation_repo, leave_wish_repo, - ws_repo, dismissal_repo, ) @@ -367,6 +365,7 @@ def get_planning_service( parental_leave_repo: ParentalLeaveRepositoryDep, vacation_repo: VacationDaysRepositoryDep, planning_engine_service: PlanningEngineServiceDep, + leave_wish_repo: LeaveWishRepositoryDep, uow: UnitOfWorkDep, ) -> PlanningService: return PlanningService( @@ -375,6 +374,7 @@ def get_planning_service( parental_leave_repo, vacation_repo, planning_engine_service, + leave_wish_repo=leave_wish_repo, uow=uow, ) diff --git a/backend/app/entities/leave_wish.py b/backend/app/entities/leave_wish.py index 43cc11b..d12f577 100644 --- a/backend/app/entities/leave_wish.py +++ b/backend/app/entities/leave_wish.py @@ -51,6 +51,21 @@ class LeaveWish: # completely unmodified. window_mode: str = "fixed" # 'fixed' | 'flexible' + # §Decision A (co-leave, 2026-09-18 correction) — dubbeldagar are OPT-IN, + # never automatic, even when `parent_mode == 'both'` and the child is + # still inside the 15-month dubbeldagar window with quota left. Only set + # when the user has explicitly requested them for this wish. NOTE: no + # frontend exposes this yet -- the period editor's dubbeldag checkbox is + # not built, and there is no `wantsDubbeldag` in the TS types, so today + # the flag is only reachable through the API. When that UI lands it must + # show the checkbox only where `WindowOutcome.dubbeldagar_legally_available` + # says they legally could apply -- see allocation_service.py. Defaults to False so every + # existing wish (and every wish that never opts in) gets the new + # PL+vacation co-leave behaviour instead of dubbeldagar. Meaningless + # outside `parent_mode == 'both'` -- `LeaveWishService._validate` rejects + # setting it otherwise. + wants_dubbeldag: bool = False + # Provenance of `name`: when a wish is created from a preset (Sommar, # Jullov, Höstlov, ...) this holds the i18n message key that produced the # translated string stored in `name` (e.g. "wishEditor.presetSommar"). diff --git a/backend/app/entities/parental_leave.py b/backend/app/entities/parental_leave.py index c89ecb5..c5e81cf 100644 --- a/backend/app/entities/parental_leave.py +++ b/backend/app/entities/parental_leave.py @@ -30,7 +30,7 @@ class ParentalLeaveFetch: personal_data_uuid: UUID workspace_id: UUID child_id: UUID - source: str # 'scraped' | 'manual' + source: str # 'scraped' | 'manual' | 'deduced' fetched_at: datetime total_days: int days_used: int diff --git a/backend/app/entities/workspace.py b/backend/app/entities/workspace.py index 170e5a2..851db83 100644 --- a/backend/app/entities/workspace.py +++ b/backend/app/entities/workspace.py @@ -9,5 +9,4 @@ from uuid import UUID class Workspace: id: UUID name: str - planning_mode: str = "solo" # 'solo' | 'couple' — explicit, not inferred from member count created_at: datetime | None = None diff --git a/backend/app/models/leave_wish.py b/backend/app/models/leave_wish.py index 78cafa4..a3ea264 100644 --- a/backend/app/models/leave_wish.py +++ b/backend/app/models/leave_wish.py @@ -46,6 +46,10 @@ class LeaveWishCreate(BaseModel): # bounding range the allocator searches within for the best-costing window. window_mode: str = "fixed" # 'fixed' | 'flexible' + # §Decision A (co-leave) — opt-in dubbeldagar; see app/entities/leave_wish.py. + # Only meaningful when parent_mode == 'both'. + wants_dubbeldag: bool = False + class LeaveWishUpdate(BaseModel): """Partial update — all fields optional; only provided fields are changed.""" @@ -78,6 +82,11 @@ class LeaveWishUpdate(BaseModel): window_mode: str | None = None # 'fixed' | 'flexible' + # `None` means "leave unchanged" here too, same as every other field on + # this partial-update model -- there is no "clear it back to False" need + # (False is already the shape-level default), so no `_clear_*` flag. + wants_dubbeldag: bool | None = None + class LeaveWishResponse(BaseModel): id: UUID @@ -103,6 +112,8 @@ class LeaveWishResponse(BaseModel): window_mode: str + wants_dubbeldag: bool + created_at: datetime | None updated_at: datetime | None @@ -127,6 +138,7 @@ class LeaveWishResponse(BaseModel): parent_mode=entity.parent_mode, parent_personal_data_uuid=entity.parent_personal_data_uuid, window_mode=entity.window_mode, + wants_dubbeldag=entity.wants_dubbeldag, created_at=entity.created_at, updated_at=entity.updated_at, ) diff --git a/backend/app/models/parental_leave.py b/backend/app/models/parental_leave.py index 5ba97ee..3b9a2ad 100644 --- a/backend/app/models/parental_leave.py +++ b/backend/app/models/parental_leave.py @@ -1,6 +1,7 @@ """Pydantic schemas for parental leave fetch endpoints.""" from datetime import datetime +from typing import Literal from uuid import UUID from pydantic import BaseModel, Field @@ -29,19 +30,25 @@ class TierDataResponse(BaseModel): class ManualFetchRequest(BaseModel): + """Per-parent "days left" manual entry — everything else (tier totals, + days used, reserved/transferable split) is derived by the same shared + function the FK scraper uses (`app.services.leave_snapshot.build_snapshot`), + so a manually-entered snapshot and a scraped one can never disagree on + how the numbers are derived. + """ + personal_data_uuid: UUID workspace_id: UUID child_id: UUID - total_days: int = Field(ge=0, le=480) - days_used: int = Field(ge=0, le=480) - reserved_days_total: int = Field(default=90, ge=0, le=240) - reserved_days_left: int = Field(ge=0, le=240) - transferable_days_left: int = Field(ge=0, le=150) - dubbeldagar_left: int = Field(default=60, ge=0, le=60) - sjukpenning_total: int = Field(default=390, ge=0, le=390) - sjukpenning_used: int = Field(ge=0, le=390) - lagsta_niva_total: int = Field(default=90, ge=0, le=90) - lagsta_niva_used: int = Field(ge=0, le=90) + sjukpenning_left: int = Field(ge=0, le=390) + lagsta_niva_left: int = Field(ge=0, le=90) + dubbeldagar_left: int = Field(ge=0, le=60) + # None -> derived from the transferable-first assumption in `build_snapshot`. + reserved_days_left: int | None = Field(default=None, ge=0, le=90) + # None -> defaults to `rules.total_days_per_child // parent_count` + # (480 solo, 240 with a partner) — see `ManualFetchService.save_manual_entry`. + total_days: int | None = Field(default=None, ge=0, le=480) + source: Literal["manual", "deduced"] = "manual" class ParentalLeaveFetchResponse(BaseModel): diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py index e4d3edb..e7c71a3 100644 --- a/backend/app/models/plan.py +++ b/backend/app/models/plan.py @@ -11,6 +11,7 @@ for the idempotency/collision/`replace` semantics. """ from datetime import date +from typing import Literal from uuid import UUID from pydantic import BaseModel, Field @@ -19,13 +20,22 @@ from app.models.planned_leave import PlannedLeaveDayResponse from app.services.allocation_service import PlanProposal from app.services.planning_service import ( DayCollision, + EvictionCandidate, + EvictionPreview, PlanApplyResult, PlanChange, PlanSyncResult, ProposedDayInput, Rejection, + ResourceShortage, ) +# Wire-level mirror of `PlanningService`'s `EVICTION_CHOICE_*` constants — +# kept as a `Literal` here (rather than importing the string constants) so +# an invalid value 422s at the Pydantic boundary instead of silently +# reaching the service as a no-op "do nothing". +EvictionChoice = Literal["drop_future_pl", "drop_latest_vacation_this_year"] + class PlanProposeRequest(BaseModel): workspace_id: UUID @@ -77,6 +87,9 @@ class WindowOutcomeResponse(BaseModel): weeks_funded: float shortfall_days: int strategy: str + requested_strategy: str + strategy_degraded: bool + dubbeldagar_legally_available: bool message_key: str data: dict[str, object] cost_breakdown: dict[str, int] @@ -164,6 +177,9 @@ class PlanProposalResponse(BaseModel): weeks_funded=o.weeks_funded, shortfall_days=o.shortfall_days, strategy=o.strategy, + requested_strategy=o.requested_strategy, + strategy_degraded=o.strategy_degraded, + dubbeldagar_legally_available=o.dubbeldagar_legally_available, message_key=o.message_key, data=o.data, cost_breakdown=o.cost_breakdown, @@ -341,6 +357,15 @@ class PlanApplyResponse(BaseModel): class PlanSyncRequest(BaseModel): workspace_id: UUID + # Which of the three user-facing options resolves a would-evict save + # (§ eviction preview). Omitted/`None` — the default — means "do + # nothing": the save is refused and nothing is mutated, exactly as if + # the user picked the explicit cancel option. Only meaningful when the + # response's `eviction_preview.would_evict` was `True` on a prior call + # with the same edit already persisted upstream (e.g. via the leave-wish + # endpoints) — a plain sync that fits within budget ignores this field + # entirely (never asked for a choice in the first place). + eviction_choice: EvictionChoice | None = None class PlanChangeResponse(BaseModel): @@ -378,10 +403,89 @@ class PlanChangeResponse(BaseModel): ) +class EvictionCandidateResponse(BaseModel): + """One previously-persisted day a fresh sync would otherwise silently + + delete — see `EvictionCandidate`'s docstring. `wish_name` is `None` when + the owning wish's name couldn't be resolved (never a reason to hide the + day itself). + """ + + date: date + parent_personal_data_uuid: UUID + child_id: UUID + day_type: str + is_dubbeldag: bool + wish_id: UUID + wish_name: str | None + + @classmethod + def from_domain(cls, candidate: EvictionCandidate) -> "EvictionCandidateResponse": + return cls( + date=candidate.date, + parent_personal_data_uuid=candidate.parent_personal_data_uuid, + child_id=candidate.child_id, + day_type=candidate.day_type, + is_dubbeldag=candidate.is_dubbeldag, + wish_id=candidate.wish_id, + wish_name=candidate.wish_name, + ) + + +class ResourceShortageResponse(BaseModel): + """One exhausted resource behind an eviction preview — see + + `ResourceShortage`'s docstring. `resource_kind == 'parental_leave'` + carries `tier`/`child_id` (`year` is `None`); `resource_kind == + 'vacation'` carries `year` instead (`tier`/`child_id` are `None`). + Structured on purpose — the frontend composes the warning text itself, + never a backend-rendered string. + """ + + resource_kind: str + parent_personal_data_uuid: UUID + tier: str | None + child_id: UUID | None + year: int | None + days_evicted: int + + @classmethod + def from_domain(cls, shortage: ResourceShortage) -> "ResourceShortageResponse": + return cls( + resource_kind=shortage.resource_kind, + parent_personal_data_uuid=shortage.parent_personal_data_uuid, + tier=shortage.tier, + child_id=shortage.child_id, + year=shortage.year, + days_evicted=shortage.days_evicted, + ) + + +class EvictionPreviewResponse(BaseModel): + """Always present on `PlanSyncResponse`; `would_evict: false` with empty + + lists in the common (fits-within-budget) case — see + `EvictionPreview`'s docstring. + """ + + would_evict: bool + candidates: list[EvictionCandidateResponse] + shortages: list[ResourceShortageResponse] + + @classmethod + def from_domain(cls, preview: EvictionPreview) -> "EvictionPreviewResponse": + return cls( + would_evict=preview.would_evict, + candidates=[EvictionCandidateResponse.from_domain(c) for c in preview.candidates], + shortages=[ResourceShortageResponse.from_domain(s) for s in preview.shortages], + ) + + class PlanSyncResponse(BaseModel): proposal: PlanProposalResponse apply_result: PlanApplyResponse changes: list[PlanChangeResponse] + eviction_preview: EvictionPreviewResponse @classmethod def from_domain(cls, result: PlanSyncResult) -> "PlanSyncResponse": @@ -389,4 +493,5 @@ class PlanSyncResponse(BaseModel): proposal=PlanProposalResponse.from_domain(result.proposal), apply_result=PlanApplyResponse.from_domain(result.apply_result), changes=[PlanChangeResponse.from_domain(c) for c in result.changes], + eviction_preview=EvictionPreviewResponse.from_domain(result.eviction_preview), ) diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py index e25314a..7b8ae99 100644 --- a/backend/app/models/workspace.py +++ b/backend/app/models/workspace.py @@ -15,13 +15,11 @@ class WorkspaceCreate(BaseModel): class WorkspaceUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=100) - planning_mode: str | None = None class WorkspaceResponse(BaseModel): id: UUID name: str - planning_mode: str created_at: datetime | None @classmethod @@ -29,7 +27,6 @@ class WorkspaceResponse(BaseModel): return cls( id=ws.id, name=ws.name, - planning_mode=ws.planning_mode, created_at=ws.created_at, ) diff --git a/backend/app/repositories_impl/sqlite/leave_wish_repository.py b/backend/app/repositories_impl/sqlite/leave_wish_repository.py index 4641c46..d4a82e1 100644 --- a/backend/app/repositories_impl/sqlite/leave_wish_repository.py +++ b/backend/app/repositories_impl/sqlite/leave_wish_repository.py @@ -45,6 +45,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): else None ), window_mode=row["window_mode"], + wants_dubbeldag=bool(row["wants_dubbeldag"]), created_at=datetime.fromisoformat(row["created_at"]), updated_at=datetime.fromisoformat(row["updated_at"]), ) @@ -60,8 +61,8 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): window_start_month, window_start_day, window_end_month, window_end_day, target_weeks, target_days, recurrence, first_year, last_year, priority, parent_mode, parent_personal_data_uuid, - window_mode, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + window_mode, wants_dubbeldag, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(item.id), @@ -86,6 +87,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): else None ), item.window_mode, + int(item.wants_dubbeldag), item.created_at.isoformat(), item.updated_at.isoformat(), ), @@ -117,7 +119,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): window_end_month = ?, window_end_day = ?, target_weeks = ?, target_days = ?, recurrence = ?, first_year = ?, last_year = ?, priority = ?, parent_mode = ?, parent_personal_data_uuid = ?, - window_mode = ?, updated_at = ? + window_mode = ?, wants_dubbeldag = ?, updated_at = ? WHERE id = ? """, ( @@ -141,6 +143,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): else None ), item.window_mode, + int(item.wants_dubbeldag), item.updated_at.isoformat(), str(id), ), diff --git a/backend/app/repositories_impl/sqlite/workspace_repository.py b/backend/app/repositories_impl/sqlite/workspace_repository.py index 355668e..cae07f6 100644 --- a/backend/app/repositories_impl/sqlite/workspace_repository.py +++ b/backend/app/repositories_impl/sqlite/workspace_repository.py @@ -25,7 +25,6 @@ class SQLiteWorkspaceRepository(WorkspaceRepository): return Workspace( id=UUID(row["id"]), name=row["name"], - planning_mode=row["planning_mode"], created_at=datetime.fromisoformat(row["created_at"]), ) @@ -33,13 +32,12 @@ class SQLiteWorkspaceRepository(WorkspaceRepository): item.created_at = item.created_at or datetime.now() await self._conn.execute( """ - INSERT INTO workspace (id, name, planning_mode, created_at) - VALUES (?, ?, ?, ?) + INSERT INTO workspace (id, name, created_at) + VALUES (?, ?, ?) """, ( str(item.id), item.name, - item.planning_mode, item.created_at.isoformat(), ), ) @@ -53,8 +51,8 @@ class SQLiteWorkspaceRepository(WorkspaceRepository): async def update(self, id: UUID, item: Workspace) -> Workspace: cursor = await self._conn.execute( - "UPDATE workspace SET name = ?, planning_mode = ? WHERE id = ?", - (item.name, item.planning_mode, str(id)), + "UPDATE workspace SET name = ? WHERE id = ?", + (item.name, str(id)), ) await commit(self._conn) if cursor.rowcount == 0: diff --git a/backend/app/routers/fetch_data.py b/backend/app/routers/fetch_data.py index bb7417b..9703525 100644 --- a/backend/app/routers/fetch_data.py +++ b/backend/app/routers/fetch_data.py @@ -8,6 +8,7 @@ Scraper endpoints (start / poll / cancel) return 501 when scraper_enabled is false. When enabled, they drive the FKScraperService session state machine. """ +import logging from uuid import UUID from fastapi import APIRouter, HTTPException, status @@ -19,6 +20,7 @@ from app.dependencies import ( ChildRepositoryDep, CurrentUserDep, ParentalLeaveRepositoryDep, + PersonalDataRepositoryDep, WorkspaceScopeDep, ) from app.entities.parental_leave import ParentalLeaveFetch @@ -27,10 +29,12 @@ from app.models.parental_leave import ( ManualFetchRequest, ParentalLeaveFetchResponse, ) -from app.scrapers.fk_scraper import FKScraperService -from app.services.fetch_data_service import ManualFetchService +from app.scrapers.fk_scraper import FKScraperService, PartnerDeduction +from app.services.fetch_data_service import ManualFetchService, persist_partner_deduction from app.utils.errors import ValidationError +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/parental-leave", tags=["parental-leave"]) scraper_router = APIRouter(prefix="/api/fetch-data", tags=["fetch-data"]) @@ -55,27 +59,27 @@ _scraper_service = FKScraperService() async def save_manual_entry( payload: ManualFetchRequest, repo: ParentalLeaveRepositoryDep, + pd_repo: PersonalDataRepositoryDep, scope: WorkspaceScopeDep, ) -> ParentalLeaveFetchResponse: scope.check_workspace(payload.workspace_id) await scope.check_member(payload.personal_data_uuid) await scope.check_child(payload.child_id) - service = ManualFetchService(repo) + # `pd_repo` lets the service default `total_days` off the workspace's + # parent count when the caller omits it — see + # `ManualFetchService.save_manual_entry`'s docstring. + service = ManualFetchService(repo, pd_repo) try: fetch = await service.save_manual_entry( personal_data_uuid=payload.personal_data_uuid, workspace_id=payload.workspace_id, child_id=payload.child_id, - total_days=payload.total_days, - days_used=payload.days_used, - reserved_days_total=payload.reserved_days_total, - reserved_days_left=payload.reserved_days_left, - transferable_days_left=payload.transferable_days_left, + sjukpenning_left=payload.sjukpenning_left, + lagsta_niva_left=payload.lagsta_niva_left, dubbeldagar_left=payload.dubbeldagar_left, - sjukpenning_total=payload.sjukpenning_total, - sjukpenning_used=payload.sjukpenning_used, - lagsta_niva_total=payload.lagsta_niva_total, - lagsta_niva_used=payload.lagsta_niva_used, + reserved_days_left=payload.reserved_days_left, + total_days=payload.total_days, + source=payload.source, ) except ValidationError as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc @@ -144,6 +148,7 @@ async def start_fetch( scope: WorkspaceScopeDep, child_repo: ChildRepositoryDep, parental_leave_repo: ParentalLeaveRepositoryDep, + personal_data_repo: PersonalDataRepositoryDep, ) -> _StartResponse: """`workspace_id` is always the caller's own — `payload.workspace_id` is accepted on the wire (frontend compatibility) but never trusted; the @@ -169,11 +174,32 @@ async def start_fetch( ) child_id = children[0].id - # Capture the repo for the background task. The connection lives for the app's - # lifetime (app.state.db_connection), so using it from the background coroutine - # is safe — aiosqlite serialises all operations through its internal queue. - async def _persist(fetch: ParentalLeaveFetch) -> None: + # Capture the repos for the background task. The connection lives for the + # app's lifetime (app.state.db_connection), so using it from the + # background coroutine is safe — aiosqlite serialises all operations + # through its internal queue. + async def _persist(fetch: ParentalLeaveFetch, partner: PartnerDeduction | None) -> None: await parental_leave_repo.create(fetch) + if partner is not None: + # Best-effort: the caller's own snapshot above already succeeded, + # so a failure deducing/persisting the partner's snapshot must + # never fail the whole scraper session (`persist_partner_deduction` + # already swallows repo errors internally — this is a second, + # redundant layer in case that ever changes). + try: + await persist_partner_deduction( + personal_data_repo=personal_data_repo, + parental_leave_repo=parental_leave_repo, + workspace_id=workspace_id, + child_id=child_id, + caller_personal_data_uuid=personal_data_uuid, + total_days=partner.total_days, + sjukpenning_left=partner.sjukpenning_left, + lagsta_niva_left=partner.lagsta_niva_left, + dubbeldagar_left=partner.dubbeldagar_left, + ) + except Exception as exc: + logger.warning("partner-deduction persist failed, session unaffected: %s", exc) session_id = await _scraper_service.start_session( workspace_id=workspace_id, diff --git a/backend/app/routers/leave_wishes.py b/backend/app/routers/leave_wishes.py index 73f0447..6ebdf86 100644 --- a/backend/app/routers/leave_wishes.py +++ b/backend/app/routers/leave_wishes.py @@ -48,6 +48,7 @@ async def create_leave_wish( parent_mode=payload.parent_mode, parent_personal_data_uuid=payload.parent_personal_data_uuid, window_mode=payload.window_mode, + wants_dubbeldag=payload.wants_dubbeldag, ) except ValidationError as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc @@ -96,6 +97,7 @@ async def update_leave_wish( parent_mode=payload.parent_mode, parent_personal_data_uuid=payload.parent_personal_data_uuid, window_mode=payload.window_mode, + wants_dubbeldag=payload.wants_dubbeldag, _clear_child_id="child_id" in fields_set and payload.child_id is None, _clear_last_year="last_year" in fields_set and payload.last_year is None, _clear_parent_personal_data_uuid=( diff --git a/backend/app/routers/plan.py b/backend/app/routers/plan.py index 09d3a89..7172788 100644 --- a/backend/app/routers/plan.py +++ b/backend/app/routers/plan.py @@ -83,10 +83,21 @@ async def sync_plan( user has pinned, and reports exactly what changed (`changes`) so the UI can show e.g. "Sommar 2028 flyttades 2 dagar — Ångra" instead of silently rewriting the calendar. + + Doubles as its own eviction preview: when the re-derived plan would + silently delete another wish's days (a shared FP/vacation pool ran out + for that wish's parent), this call writes NOTHING and returns + `eviction_preview` describing what ran out and which days would go — + unless `eviction_choice` names one of the two caller-authorized + eviction strategies, in which case exactly that category is freed and + the edit is applied. No separate round trip for the common (fits- + within-budget) case; see `PlanningService.sync_plan`'s docstring. """ scope.check_workspace(payload.workspace_id) try: - result = await service.sync_plan(payload.workspace_id) + result = await service.sync_plan( + payload.workspace_id, eviction_choice=payload.eviction_choice + ) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc except ValidationError as exc: diff --git a/backend/app/routers/workspace.py b/backend/app/routers/workspace.py index d2360ab..4c3b976 100644 --- a/backend/app/routers/workspace.py +++ b/backend/app/routers/workspace.py @@ -84,7 +84,7 @@ async def update_workspace( service: WorkspaceServiceDep, ) -> WorkspaceResponse: try: - ws = await service.update_workspace(workspace_id, payload.name, payload.planning_mode) + ws = await service.update_workspace(workspace_id, payload.name) except (NotFoundError, ValidationError) as exc: code = ( status.HTTP_404_NOT_FOUND diff --git a/backend/app/scrapers/fk_scraper.py b/backend/app/scrapers/fk_scraper.py index 83c4aa3..421a270 100644 --- a/backend/app/scrapers/fk_scraper.py +++ b/backend/app/scrapers/fk_scraper.py @@ -33,15 +33,16 @@ import logging import re import uuid from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from datetime import datetime from pathlib import Path from typing import Literal from uuid import UUID from app.config import settings -from app.entities.parental_leave import ParentalLeaveFetch, TierData +from app.entities.parental_leave import ParentalLeaveFetch from app.services.fetch_data_service import FetchDataService +from app.services.leave_snapshot import build_snapshot logger = logging.getLogger(__name__) @@ -85,6 +86,46 @@ class _SEL: # niva-statistik items inside forDig (desktop version, first = sjukpenning, second = lägstanivå) NIVA_STATISTIK_DESKTOP = "[data-test='forDig'] .visa-dagar__desktop .niva-statistik__dagar span" + # "För dig och annan förälder" family panel — shows the CHILD's combined + # total ("Totalt för "), from which the other parent's remaining + # days can be deduced (family - mine). The exact DOM here is ASSUMED to + # mirror forDig's structure (same visa-dagar/niva-statistik classes) + # pending a real capture — see `fk_overview_synthetic.html`'s header + # comment. Parsing against these selectors must stay fully defensive + # (see `_extract_family_summary`): if FK's real markup differs, the + # feature degrades to "no partner deduction" rather than breaking the + # caller's own extraction. + FAMILY = "[data-test='forDigOchAnnanForalder']" + FAMILY_DAGAR_KVAR = FAMILY + " .visa-dagar__dagar-kvar" + FAMILY_DISCRETE = FAMILY + " .visa-dagar__discrete" + FAMILY_NIVA_STATISTIK = FAMILY + " .visa-dagar__desktop .niva-statistik__dagar span" + + +# --------------------------------------------------------------------------- +# Family-panel summary and partner deduction +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class FamilySummary: + """Parsed "Totalt för " family-panel figures (the CHILD's combined pool).""" + + total_days: int + days_left: int + sjukpenning_left: int | None + lagsta_niva_left: int | None + + +@dataclass(frozen=True) +class PartnerDeduction: + """The other parent's balance, deduced as family totals minus mine.""" + + total_days: int + days_left: int + sjukpenning_left: int + lagsta_niva_left: int + dubbeldagar_left: int + # --------------------------------------------------------------------------- # Session state @@ -110,12 +151,20 @@ class _SessionState: result: ParentalLeaveFetch | None = None message: str | None = None created_at: datetime = field(default_factory=datetime.now) + # Family-panel summary and the deduced partner balance, when the family + # panel was present and its numbers were internally consistent with + # `result` — see `_extract_family_summary`/`_deduce_partner`. Both stay + # None whenever the family panel is absent, unparsable, or inconsistent; + # that never affects `result` (the caller's own extraction). + family: FamilySummary | None = None + partner_deduction: PartnerDeduction | None = None # Called after a successful extraction to persist the extracted numbers. - # The callback receives the validated ParentalLeaveFetch and must write it to the DB. - # Only the extracted numbers are persisted — never cookies, QR, or session state. - persist_callback: Callable[[ParentalLeaveFetch], Awaitable[None]] | None = field( - default=None, repr=False, compare=False - ) + # The callback receives the validated ParentalLeaveFetch (mine) and the + # deduced partner balance (or None). Only the extracted numbers are + # persisted — never cookies, QR, or session state. + persist_callback: ( + Callable[[ParentalLeaveFetch, PartnerDeduction | None], Awaitable[None]] | None + ) = field(default=None, repr=False, compare=False) # asyncio task running the scrape; kept so we can cancel it _task: asyncio.Task | None = field(default=None, repr=False, compare=False) @@ -158,7 +207,9 @@ class FKScraperService(FetchDataService): personal_data_uuid: UUID, child_id: UUID, owner_user_id: UUID, - persist_callback: Callable[[ParentalLeaveFetch], Awaitable[None]] | None = None, + persist_callback: ( + Callable[[ParentalLeaveFetch, PartnerDeduction | None], Awaitable[None]] | None + ) = None, ) -> str: """Launch a new headless browser session. Returns the session_id. @@ -215,6 +266,9 @@ class FKScraperService(FetchDataService): response["qr_data"] = base64.b64encode(state.qr_data).decode() if state.status == "done" and state.result: response["result"] = state.result + response["partner_deduction"] = ( + asdict(state.partner_deduction) if state.partner_deduction else None + ) if state.message: response["message"] = state.message return response @@ -363,6 +417,8 @@ async def _run_session(state: _SessionState) -> None: if state.status in ("error", "cancelled"): return await _do_extract(page, state) + if state.status == "done" and settings.scraper_debug_dump_on_success: + await _save_debug_artifacts(page, state.session_id, console_messages) except asyncio.CancelledError: _set_status(state, "cancelled") except Exception as exc: @@ -503,9 +559,22 @@ async def _do_extract(page, state: _SessionState) -> None: data = await _extract_parental_leave(page, state) + # Family panel ("för dig och annan förälder") -> deduce the partner's + # balance. Best-effort only: any failure here must never affect `data`, + # which is already a fully validated, ready-to-persist result. + try: + family = await _extract_family_summary(page, state) + state.family = family + if family is not None: + state.partner_deduction = _deduce_partner(data, family) + except Exception as exc: + logger.info("[%s] family-panel deduction skipped: %s", state.session_id, type(exc).__name__) + state.family = None + state.partner_deduction = None + if state.persist_callback is not None: try: - await state.persist_callback(data) + await state.persist_callback(data, state.partner_deduction) except Exception as exc: _set_status( state, @@ -541,8 +610,7 @@ async def _extract_parental_leave(page, state: _SessionState) -> ParentalLeaveFe "FK panel returned total_days=0 — data had not hydrated when read " "(placeholder state). The hydration wait in _do_extract should prevent this." ) - days_used = total_days - days_left - logger.debug("[%s] total/used parsed", state.session_id) + logger.debug("[%s] total days parsed", state.session_id) # -- sjukpenningnivå and lägstanivå remaining (desktop niva-statistik) -- # all_inner_texts() does not accept a timeout argument — wait for the first element first. @@ -575,57 +643,155 @@ async def _extract_parental_leave(page, state: _SessionState) -> ParentalLeaveFe dubbeldagar_left = int(dubbeldag_text.strip()) logger.debug("[%s] dubbeldagar parsed", state.session_id) - # -- Derive tier totals (standard FK allocation per parent) -- - # Standard: 195 sjukpenningnivå + 45 lägstanivå = 240 per parent. - # Transfers can change this, but the page doesn't expose the totals directly. - # Assumption: lägstanivå days are never used before sjukpenning (most common case). - lagsta_niva_total = 45 # standard per-parent lägstanivå allocation (= rules.lagsta_niva_days/2) - sjukpenning_total = total_days - lagsta_niva_total # typically 195 for standard 240-day split - lagsta_niva_used = lagsta_niva_total - lagsta_niva_left - sjukpenning_used = sjukpenning_total - sjukpenning_left - - # -- Derive reserved / transferable split -- - # Reserved days (90 per parent) cannot be transferred; used assumption: transferable taken first. - reserved_days_total = settings.rules.reserved_days_per_parent # 90 - transferable_total = total_days - reserved_days_total # 150 for standard 240-day case - transferable_used = min(days_used, transferable_total) - reserved_used = days_used - transferable_used - reserved_days_left = reserved_days_total - reserved_used - transferable_days_left = transferable_total - transferable_used - - # -- Validate before returning -- - if days_left < 0: - raise ValueError(f"days_left is negative ({days_left}) — FK data inconsistency") - if reserved_days_left > reserved_days_total: - raise ValueError( - f"reserved_days_left ({reserved_days_left}) > reserved_days_total ({reserved_days_total})" - ) - - now = datetime.now() - tier_data = TierData( - sjukpenning_total=sjukpenning_total, - sjukpenning_used=sjukpenning_used, - sjukpenning_left=sjukpenning_left, - lagsta_niva_total=lagsta_niva_total, - lagsta_niva_used=lagsta_niva_used, - lagsta_niva_left=lagsta_niva_left, - ) - - return ParentalLeaveFetch( - id=uuid.uuid4(), + # -- Derive tier totals, reserved/transferable split, and validate -- + # The page doesn't expose tier totals or the reserved/transferable split + # directly, only totals-per-tier-left and the grand total; everything + # else is derived by the single shared function also used by manual + # entry, so the two paths can never disagree (see + # `app.services.leave_snapshot.build_snapshot`'s docstring). This + # raises `ValidationError` (not `ValueError`) on bad data — see + # `tests/unit/test_fk_scraper.py::TestValidation` for the exact + # messages a caller may match on. + return build_snapshot( personal_data_uuid=state.personal_data_uuid, workspace_id=state.workspace_id, child_id=state.child_id, source="scraped", - fetched_at=now, total_days=total_days, - days_used=days_used, - days_left=days_left, - reserved_days_total=reserved_days_total, - reserved_days_left=reserved_days_left, - transferable_days_left=transferable_days_left, + sjukpenning_left=sjukpenning_left, + lagsta_niva_left=lagsta_niva_left, dubbeldagar_left=dubbeldagar_left, - tier_data=tier_data, - created_at=now, - updated_at=now, + ) + + +async def _extract_family_summary(page, state: _SessionState) -> FamilySummary | None: + """Read the "för dig och annan förälder" family panel, if present and parsable. + + Defensive by design: the real FK DOM for this panel is unconfirmed (see + `_SEL.FAMILY*`'s comment), so any missing element or unexpected text is + treated as "no family data available" — logged at INFO and returned as + None — never raised. The caller's own `data` (this parent's snapshot) + must succeed regardless of what happens here. + """ + try: + family_panel = page.locator(_SEL.FAMILY) + if await family_panel.count() == 0: + logger.info("[%s] family panel not present; skipping deduction", state.session_id) + return None + + dagar_kvar_loc = page.locator(_SEL.FAMILY_DAGAR_KVAR) + if await dagar_kvar_loc.count() == 0: + logger.info( + "[%s] family panel dagar-kvar element not found; skipping deduction", + state.session_id, + ) + return None + dagar_kvar_text = await dagar_kvar_loc.first.inner_text(timeout=2_000) + days_left = int(dagar_kvar_text.strip()) + + discrete_loc = page.locator(_SEL.FAMILY_DISCRETE) + if await discrete_loc.count() == 0: + logger.info( + "[%s] family panel discrete element not found; skipping deduction", + state.session_id, + ) + return None + discrete_text = await discrete_loc.first.inner_text(timeout=2_000) + total_match = re.search(r"av\s+(\d+)\s+dagar", discrete_text) + if not total_match: + logger.info( + "[%s] could not parse 'av N dagar' from family panel text; skipping deduction", + state.session_id, + ) + return None + total_days = int(total_match.group(1)) + + # Tier spans are optional — best-effort, absence doesn't invalidate the summary. + sjukpenning_left: int | None = None + lagsta_niva_left: int | None = None + niva_loc = page.locator(_SEL.FAMILY_NIVA_STATISTIK) + if await niva_loc.count() > 0: + niva_texts = await niva_loc.all_inner_texts() + niva_numbers = [] + for t in niva_texts: + m = re.search(r"\((\d+)\s+dagar\)", t) + if m: + niva_numbers.append(int(m.group(1))) + if len(niva_numbers) == 2: + break + if len(niva_numbers) == 2: + sjukpenning_left, lagsta_niva_left = niva_numbers[0], niva_numbers[1] + + return FamilySummary( + total_days=total_days, + days_left=days_left, + sjukpenning_left=sjukpenning_left, + lagsta_niva_left=lagsta_niva_left, + ) + except Exception as exc: + logger.info( + "[%s] family panel parsing failed (%s); skipping deduction", + state.session_id, + type(exc).__name__, + ) + return None + + +def _deduce_partner(mine: ParentalLeaveFetch, family: FamilySummary) -> PartnerDeduction | None: + """Deduce the other parent's balance as family totals minus mine. + + Returns None (logging a warning) whenever the numbers are internally + inconsistent — e.g. the family panel reports fewer days than this parent + alone already accounts for, which can only mean a stale read or a DOM + assumption that doesn't match FK's real markup. + """ + total = family.total_days - mine.total_days + left = family.days_left - mine.days_left + + if total <= 0 or left < 0 or left > total: + logger.warning( + "family/partner deduction inconsistent: family_total=%d family_left=%d " + "mine_total=%d mine_left=%d -> deduced_total=%d deduced_left=%d", + family.total_days, + family.days_left, + mine.total_days, + mine.days_left, + total, + left, + ) + return None + + rules = settings.rules + + if family.sjukpenning_left is not None and family.lagsta_niva_left is not None: + sjuk_left = family.sjukpenning_left - mine.tier_data.sjukpenning_left + lagsta_left = family.lagsta_niva_left - mine.tier_data.lagsta_niva_left + if sjuk_left < 0 or lagsta_left < 0: + logger.warning( + "family/partner tier deduction inconsistent (sjuk=%d, lagsta=%d); " + "falling back to proportional split", + sjuk_left, + lagsta_left, + ) + lagsta_share = ( + rules.lagsta_niva_days * total // (rules.sjukpenning_days + rules.lagsta_niva_days) + ) + lagsta_left = min(left, lagsta_share) + sjuk_left = left - lagsta_left + else: + # No family-level tier breakdown available — assume lägstanivå is + # untouched first (mirrors the same "transferable spent first" + # assumption `build_snapshot` makes for the reserved/transferable split). + lagsta_share = ( + rules.lagsta_niva_days * total // (rules.sjukpenning_days + rules.lagsta_niva_days) + ) + lagsta_left = min(left, lagsta_share) + sjuk_left = left - lagsta_left + + return PartnerDeduction( + total_days=total, + days_left=left, + sjukpenning_left=sjuk_left, + lagsta_niva_left=lagsta_left, + dubbeldagar_left=mine.dubbeldagar_left, ) diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index 6309a4a..8ec8b99 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -93,9 +93,22 @@ WARNING_VACATION_EXPIRING = "vacation_expiring" # the existing `WindowOutcome.strategy` value set ('solo' | 'parent_swap' | # 'dubbeldag') — a bare str on both the dataclass and the wire model, so a # new literal value is a behavior change, not a schema change. +# +# §Decision A/C (2026-09-18 co-leave fix) extended that set again: +# 'co_leave' — a `parent_mode == 'both'` window funded as PL (to whichever +# parent has the most FP left) + vacation (the other parent) on the SAME +# date, the new default for 'both' whenever dubbeldagar are not requested +# or not legally available. 'swap' — the true last resort within a 'both' +# window: a date neither co-leave resource could cover, handed to the same +# sequential-halves machinery 'parent_swap' uses. `strategy` always reports +# what actually happened, never what was merely hoped for; `requested_strategy` +# carries the latter, and `strategy_degraded` is true whenever they differ +# (see `WindowOutcome` below). # --------------------------------------------------------------------------- STRATEGY_UNPLACED = "unplaced" +STRATEGY_CO_LEAVE = "co_leave" +STRATEGY_SWAP = "swap" # §3.5 — i18n message keys. Every UI-facing piece of text the (pure) allocator # used to render as a full Swedish sentence is now a `message_key` + `data` @@ -112,11 +125,11 @@ _SUNDAY_HOLIDAY_NAME = SUNDAY_HOLIDAY_NAME _DUBBELDAGAR_MONTHS_LIMIT = settings.rules.dubbeldagar_months_limit _MAX_DUBBELDAGAR = settings.rules.max_dubbeldagar # NOTE: no module-level "_MAX_SAVABLE_AT_B4" here on purpose — which age-4 cap -# applies (96 per child vs. 48 per parent) depends on the workspace's -# `planning_mode` and parent count, so it is resolved once per `propose()` -# run by `AllocationInputLoader` onto `AllocationInput.age4_savable_cap` and -# read from there (`self._input.age4_savable_cap`) everywhere `_Ledger` needs -# it — never as a constant computed at import time. +# applies (96 per child vs. 48 per parent) depends purely on parent count, so +# it is resolved once per `propose()` run by `AllocationInputLoader` onto +# `AllocationInput.age4_savable_cap` and read from there +# (`self._input.age4_savable_cap`) everywhere `_Ledger` needs it — never as a +# constant computed at import time. _SGI_MIN_DAYS_PER_WEEK = settings.rules.sgi_min_days_per_week_after_age_1 # A "week off" costs ~5 spendable weekdays (Sat/Sun are already free) — see # §3.4: "a 6-week summer window is ~42 calendar days but only ~30 spendable @@ -188,11 +201,11 @@ class VacationBudget: class AllocationInput: today: date horizon_end: date - planning_mode: str # 'solo' | 'couple' # The age-4 savable-days ceiling that applies to THIS workspace, resolved # once by the loader via `app.utils.tip_predicates.resolve_age4_savable_cap` - # (96 per child, or 48 per parent for a couple workspace with 2 parents) — - # never re-derived inside the (pure) `_Ledger`. + # (96 per child for a genuine single parent, or 48 per parent — + # non-poolable — as soon as the child has 2+ parents) — never re-derived + # inside the (pure) `_Ledger`. age4_savable_cap: int children: list[ChildFacts] parents: list[ParentFacts] @@ -206,6 +219,22 @@ class AllocationInput: # despite the calendar. `is_free_day` is the single place these are # merged on top of `holidays`. day_overrides: dict[date, bool] = field(default_factory=dict) + # §fix (parent-reassignment moves dates, decision B) — the FULL, + # never-filtered snapshot of every persisted `planned_leave_day` row for + # this workspace, independent of `existing_planned` above (which + # `only_pinned_existing=True` — see `AllocationInputLoader.load` — + # narrows to pinned/ad-hoc rows only, so `sync_plan`'s re-plan can + # legitimately re-derive an unpinned wish-attributed day). `None` (the + # default every hand-built `AllocationInput` in the test suite gets + # unless it opts in) means "same as `existing_planned`" — see + # `_Ledger.__init__`. Read ONLY by `_Ledger._persisted_by_wish` (via + # `_persisted_dates_for_wish`), which needs to find a flexible wish's + # own prior placement to reuse it (`_reuse_already_funded_window`) even + # when that placement is currently hidden from `existing_planned` and + # even when it was funded under a parent the wish no longer resolves to + # (the wish was reassigned) — matched by `wish_id`, not by parent, for + # exactly that reason. Never used for resource netting or `_existing_lookup`. + all_existing_planned: list[PlannedLeaveDay] | None = None # --------------------------------------------------------------------------- @@ -270,7 +299,8 @@ class Age4Projection: projected_left_at_b4: int savable_after_b4: int # min(projected_left_at_b4, savable_cap_at_b4) expiring_at_b4: int # max(projected_left_at_b4 - savable_cap_at_b4, 0) — LOST if not used - # The cap actually applied — see `app.utils.tip_predicates.resolve_age4_savable_cap`. + # The cap actually applied — 96 (single parent) or 48 (non-poolable, + # 2+ parents) — see `app.utils.tip_predicates.resolve_age4_savable_cap`. savable_cap_at_b4: int @@ -290,7 +320,29 @@ class WindowOutcome: weeks_wished: int weeks_funded: float shortfall_days: int + # What actually happened: 'solo' | 'parent_swap' | 'dubbeldag' | + # 'co_leave' | 'swap' | 'unplaced'. Never lies — a 'both'-mode window + # that degrades from dubbeldagar/co-leave down to a sequential swap for + # some (or all) of its dates reports 'swap' or 'co_leave' here, not the + # requested-but-unachieved 'dubbeldag'. See `requested_strategy`. strategy: str + # What `parent_mode` (+ `wants_dubbeldag`) asked for, independent of + # whether it was achieved: 'solo' | 'parent_swap' | 'dubbeldag' | + # 'co_leave'. Compare against `strategy` to tell "as requested" from + # "fell back" — the frontend surfaces this distinction (e.g. a banner + # when a 'both' wish couldn't keep both parents on every date). + requested_strategy: str + # True when `strategy != requested_strategy` for a 'both'-mode window: + # the whole-window comparison already computed by `requested_strategy` + # vs. `strategy`) sees a date that couldn't be reached this run. + strategy_degraded: bool + # Whether dubbeldagar were a legally available option for this window + # (child inside the 15-month limit, quota left, both parents held FP) — + # regardless of whether `wish.wants_dubbeldag` was set. Only meaningful + # for a 'both'-mode window (always False otherwise). The frontend's + # period editor uses this to decide whether to SHOW the dubbeldag + # opt-in checkbox at all. + dubbeldagar_legally_available: bool message_key: str # i18n key for frontend — always MESSAGE_KEY_WINDOW_EXPLANATION today data: dict[str, object] cost_breakdown: dict[str, int] @@ -623,6 +675,51 @@ def _new_cost() -> dict[str, int]: return dict(_EMPTY_COST) +def _shortfall_and_weeks_funded( + mode: str, + all_dates: list[date], + days: list["ProposedDay"], + cost: dict[str, int], +) -> tuple[int, float]: + """Shared by `_Ledger.fund_window` (real) and `_Ledger._candidate_shortfall` + + (§Decision C's budget-aware probe) so the two can never silently + disagree on what "funded" means for the same (mode, dates, days, cost). + + 'dubbeldag' mode (both parents, §Decision A) spends TWO resource units + per co-funded date (one per parent — dubbeldagar and co-leave both emit + 2 `ProposedDay`s per date, see `_fund_both`), so the cost-dict-sum math + below (built for exactly one resource per calendar date) would + double-count every co-funded date. Counted by DATE instead there: a + date is "funded" once at least one parent has a non-free `ProposedDay` + for it. + + Every other mode ('solo' | 'swap') counts via `cost` rather than + `days`, deliberately: `_fund_solo` skips emitting a `ProposedDay` + entirely for a date `_existing_lookup` already has (a previously + persisted/applied day — see its docstring), but still increments + `cost[existing_type]` for it. A `days`-based count would silently + treat every already-applied date as unfunded, which is exactly wrong + for `_candidate_shortfall`'s purpose: ranking a candidate that reuses + already-persisted dates (§fix, decision B) must see those dates as + funded, not missing. + """ + if mode == "dubbeldag": + funded_dates = {d.date for d in days if d.day_type != "free"} + free_dates = {d.date for d in days if d.day_type == "free"} + spendable_total = len(all_dates) - len(free_dates) + funded_spendable = len(funded_dates) + shortfall_days = spendable_total - funded_spendable + weeks_funded = round((len(free_dates) + len(funded_dates)) / 7, 2) + else: + funded_home_days = cost["free"] + cost["vacation"] + cost["sjukpenning"] + cost["lagsta"] + spendable_total = len(all_dates) - cost["free"] + funded_spendable = funded_home_days - cost["free"] + shortfall_days = spendable_total - funded_spendable + weeks_funded = round(funded_home_days / 7, 2) + return shortfall_days, weeks_funded + + class _Ledger: """Mutable bookkeeping for a single, pure `Allocator.propose()` run.""" @@ -646,18 +743,58 @@ class _Ledger: (p.parent_personal_data_uuid, p.child_id, p.date): p.day_type for p in alloc_input.existing_planned } - # §fix (double-booking on flexible re-propose) — an immutable snapshot - # of ONLY truly-persisted planned days, taken before `record_days()` - # ever folds in this run's freshly-computed (not-yet-applied) windows. + # §eviction-preview fix — the KEYS of `_existing_lookup` as of THIS + # point only, before `record_days()` (called after every + # `fund_window()`, see below) starts folding in this run's own + # freshly-computed placements. `_fund_solo`'s cost-crediting (below) + # consults this to tell "this date is already a genuinely pre- + # existing/pinned commitment -- count it toward THIS window's + # funded total" from "a DIFFERENT, earlier-processed wish just + # claimed this date THIS SAME RUN" -- the latter must never be + # credited to a later-processed wish's own `shortfall_days` + # (crediting it was the actual bug: a lower-priority wish's window + # silently reported `shortfall_days == 0` even though every one of + # its dates had actually gone to a different wish, which meant + # `_genuinely_evicted_rows` could never detect the eviction its own + # persisted rows were about to suffer). + self._pre_run_occupied: frozenset[tuple[UUID, UUID | None, date]] = frozenset( + self._existing_lookup + ) + # §fix (double-booking on flexible re-propose; extended for §fix + # parent-reassignment, decision B) — an immutable snapshot of ONLY + # truly-persisted planned days, taken before `record_days()` ever + # folds in this run's freshly-computed (not-yet-applied) windows. # `_existing_lookup` above is mutated by `record_days()` as pass 1/2 # proceed, so a fixed wish's just-computed placement would otherwise # be indistinguishable from a flexible wish's own prior occurrence # (see `TestFixedWishNeverDisplacedByFlexible`, which must still see - # an empty view here). `_reuse_already_funded_window` is the only - # reader. - self._persisted_lookup: dict[tuple[UUID, UUID | None, date], str] = dict( - self._existing_lookup + # an empty view here). + # + # Sourced from `all_existing_planned` when the caller provided one + # (production always does via `AllocationInputLoader`) — the FULL, + # never-`only_pinned_existing`-filtered snapshot — so this index + # still finds a wish's prior placement even when `existing_planned` + # itself has been narrowed down for a `sync_plan` re-plan. Falls + # back to `existing_planned` when `all_existing_planned` is `None` + # (every hand-built `AllocationInput` in the test suite that doesn't + # opt in) so the two stay equivalent absent that narrowing. + # + # Grouped by (wish_id, child_id) rather than by parent: matching by + # parent would silently stop finding a wish's own prior window the + # moment the wish is reassigned to a different parent — exactly the + # dates-move-on-reassignment regression this index exists to + # prevent. `_persisted_dates_for_wish` is the only reader. + persisted_source = ( + alloc_input.all_existing_planned + if alloc_input.all_existing_planned is not None + else alloc_input.existing_planned ) + self._persisted_by_wish: dict[tuple[UUID, UUID | None], set[date]] = {} + for p in persisted_source: + if p.wish_id is None: + continue + key = (p.wish_id, p.child_id) + self._persisted_by_wish.setdefault(key, set()).add(p.date) self._reserve_pinned_days() self._reserve_preschool_days() @@ -716,8 +853,12 @@ class _Ledger: # -- parent resolution (§3.3, parent_swap) --------------------------- def _resolve_parents(self, wish: LeaveWish) -> _Responsible: + # Couple wish-assignment (swap/dubbeldag) kicks in purely on parent + # count — whether the workspace has a second parent at all. There is + # no separate "planning mode" flag any more: adding a partner to the + # workspace IS the signal. parents = self._input.parents - if len(parents) < 2 or self._input.planning_mode != "couple": + if len(parents) < 2: return _Responsible(mode="solo", parents=[self._pick_solo_parent(wish, parents)]) if wish.parent_mode == "specific": @@ -899,9 +1040,20 @@ class _Ledger: pool_key = (parent.uuid, child_id) if child_id is not None else None for d in dates: - existing_type = self._existing_lookup.get((parent.uuid, child_id, d)) + key = (parent.uuid, child_id, d) + existing_type = self._existing_lookup.get(key) if existing_type is not None: - cost[existing_type] = cost.get(existing_type, 0) + 1 + # §eviction-preview fix — only a genuinely pre-existing/ + # pinned commitment (present in `_existing_lookup` BEFORE + # this run's own `record_days()` calls started, see + # `_pre_run_occupied`) counts toward THIS window's funded + # total. A date claimed by a DIFFERENT, earlier-processed + # wish this same run is simply unavailable to this one -- + # falling through unfunded (never `continue`d past without + # emitting a day) is what lets `shortfall_days` correctly + # reflect the loss instead of silently masking it. + if key in self._pre_run_occupied: + cost[existing_type] = cost.get(existing_type, 0) + 1 continue if is_free_day(d, self._input.holidays, self._input.day_overrides): @@ -1019,27 +1171,76 @@ class _Ledger: dates: list[date], red_cluster: set[date], responsible: _Responsible, - ) -> tuple[list[ProposedDay], dict[str, int]]: + ) -> tuple[list[ProposedDay], dict[str, int], dict[str, int | bool]]: + """§Decision A (co-leave, 2026-09-18 correction) — both parents off + + on the SAME dates. Three tiers, tried in order for every date: + + 1. Dubbeldagar — ONLY when `wish.wants_dubbeldag` is set (opt-in, + never automatic — see `LeaveWish.wants_dubbeldag`) AND the date + is inside the child's 15-month window AND quota remains AND + both parents currently hold FP (peeked, then committed + together — never a lone half-spend). + 2. Co-leave (PL + vacation) — the default for every other date: + the parent with the MOST remaining FP (`sjukpenning_left + + lagsta_left`, at the live, mutating balance — ties go to + `responsible.parents[0]`, deterministic since that list order + is itself deterministic) takes an FP day (sjukpenning + preferred); the other parent takes a vacation day, same date. + Peeked-then-committed together too: if either side can't pay, + NEITHER is spent for that date. + 3. Swap (last resort) — a date co-leave genuinely cannot fund + (the higher-FP parent has none left, or the other parent has + no vacation left) is handed to `_fund_swap` together with the + rest of that leftover run, which splits it into sequential + single-parent halves — surrendering the "both off" property + for just that date, not the whole window. + + Returns a third element (`meta`) — dubbeldag/co_leave/swap DATE + counts (not day-instances: one per calendar date, however many + `ProposedDay`s it produced) plus whether dubbeldagar were legally + available at all — that `fund_window` uses to derive + `strategy`/`requested_strategy`/`strategy_degraded`/ + `dubbeldagar_legally_available`. + """ parent_a, parent_b = responsible.parents[0], responsible.parents[1] child = self._resolve_wish_child(wish) if child is None: - return self._fund_swap(wish, w, dates, red_cluster, responsible) + days, cost = self._fund_swap(wish, w, dates, red_cluster, responsible) + meta: dict[str, int | bool] = { + "dubbeldag_dates": 0, + "co_leave_dates": 0, + "swap_dates": len({d.date for d in days}), + "dubbeldagar_legally_available": False, + } + return days, cost, meta self._maybe_clamp_age4((parent_a.uuid, child.id), w.start) self._maybe_clamp_age4((parent_b.uuid, child.id), w.start) + pool_a = self._pools.get((parent_a.uuid, child.id)) + pool_b = self._pools.get((parent_b.uuid, child.id)) + eligible_end = child.birth_date + relativedelta(months=_DUBBELDAGAR_MONTHS_LIMIT) + used = self._dubbeldagar_used.get(child.id, 0) + dubbeldagar_legally_available = ( + pool_a is not None + and pool_b is not None + and any(d <= eligible_end for d in dates) + and used < _MAX_DUBBELDAGAR + and self._peek_fp_tier(pool_a) is not None + and self._peek_fp_tier(pool_b) is not None + ) + want_dubbeldag = wish.wants_dubbeldag + days: list[ProposedDay] = [] cost = _new_cost() - used = self._dubbeldagar_used.get(child.id, 0) + dubbeldag_dates: set[date] = set() + co_leave_dates: set[date] = set() leftover_for_swap: list[date] = [] for d in dates: - if d > eligible_end: - leftover_for_swap.append(d) - continue - existing_a = self._existing_lookup.get((parent_a.uuid, child.id, d)) existing_b = self._existing_lookup.get((parent_b.uuid, child.id, d)) if existing_a is not None or existing_b is not None: @@ -1057,36 +1258,82 @@ class _Ledger: days.append(ProposedDay(d, parent_b.uuid, child.id, "free", False, wish.id, reason)) continue - if used >= _MAX_DUBBELDAGAR: - leftover_for_swap.append(d) - continue + funded = False - pool_a = self._pools.get((parent_a.uuid, child.id)) - pool_b = self._pools.get((parent_b.uuid, child.id)) - if pool_a is None or pool_b is None: - leftover_for_swap.append(d) - continue + if ( + want_dubbeldag + and pool_a is not None + and pool_b is not None + and d <= eligible_end + and used < _MAX_DUBBELDAGAR + ): + tier_a = self._peek_fp_tier(pool_a) + tier_b = self._peek_fp_tier(pool_b) + if tier_a is not None and tier_b is not None: + self._commit_fp_tier(pool_a, tier_a) + self._commit_fp_tier(pool_b, tier_b) + used += 1 + cost[tier_a] += 1 + cost[tier_b] += 1 + days.append( + ProposedDay( + d, parent_a.uuid, child.id, tier_a, True, wish.id, REASON_DUBBELDAG + ) + ) + days.append( + ProposedDay( + d, parent_b.uuid, child.id, tier_b, True, wish.id, REASON_DUBBELDAG + ) + ) + dubbeldag_dates.add(d) + funded = True - tier_a = self._peek_fp_tier(pool_a) - tier_b = self._peek_fp_tier(pool_b) - if tier_a is None or tier_b is None: - leftover_for_swap.append(d) - continue + if not funded and pool_a is not None and pool_b is not None: + total_a = pool_a.sjukpenning_left + pool_a.lagsta_left + total_b = pool_b.sjukpenning_left + pool_b.lagsta_left + # Ties go to parent_a (`responsible.parents[0]`) — that list + # is itself deterministically ordered, so this stays stable + # across repeated `propose()` runs (§5 test #11). + if total_a >= total_b: + fp_parent, fp_pool, other_parent = parent_a, pool_a, parent_b + else: + fp_parent, fp_pool, other_parent = parent_b, pool_b, parent_a - self._commit_fp_tier(pool_a, tier_a) - self._commit_fp_tier(pool_b, tier_b) - used += 1 - cost[tier_a] += 1 - cost[tier_b] += 1 - days.append( - ProposedDay(d, parent_a.uuid, child.id, tier_a, True, wish.id, REASON_DUBBELDAG) - ) - days.append( - ProposedDay(d, parent_b.uuid, child.id, tier_b, True, wish.id, REASON_DUBBELDAG) - ) + fp_tier = self._peek_fp_tier(fp_pool) + other_budget = self._vacation.get((other_parent.uuid, d.year)) + other_can_pay_vacation = other_budget is not None and other_budget.available > 0 + + if fp_tier is not None and other_can_pay_vacation: + self._commit_fp_tier(fp_pool, fp_tier) + self._spend_vacation(other_parent.uuid, d) + cost[fp_tier] += 1 + cost["vacation"] += 1 + fp_reason = ( + REASON_FP_INTERIOR if fp_tier == "sjukpenning" else REASON_FP_LAGSTA_STRETCH + ) + days.append( + ProposedDay(d, fp_parent.uuid, child.id, fp_tier, False, wish.id, fp_reason) + ) + days.append( + ProposedDay( + d, + other_parent.uuid, + child.id, + "vacation", + False, + wish.id, + REASON_VACATION_WRAP_WEEKEND, + ) + ) + co_leave_dates.add(d) + funded = True + + if not funded: + leftover_for_swap.append(d) self._dubbeldagar_used[child.id] = used + swap_dates: set[date] = set() if leftover_for_swap: swap_days, swap_cost = self._fund_swap( wish, w, sorted(leftover_for_swap), red_cluster, responsible @@ -1094,8 +1341,15 @@ class _Ledger: days.extend(swap_days) for k in cost: cost[k] += swap_cost[k] + swap_dates = {sd.date for sd in swap_days} - return days, cost + meta = { + "dubbeldag_dates": len(dubbeldag_dates), + "co_leave_dates": len(co_leave_dates), + "swap_dates": len(swap_dates), + "dubbeldagar_legally_available": dubbeldagar_legally_available, + } + return days, cost, meta # -- per-window orchestration (§3.3-3.6) ------------------------------- @@ -1108,12 +1362,35 @@ class _Ledger: w.start, w.end, self._input.holidays, self._input.day_overrides ) + dubbeldagar_legally_available = False + strategy_degraded = False + if responsible.mode == "dubbeldag": - days, cost = self._fund_both(wish, w, all_dates, red_cluster, responsible) - strategy = "dubbeldag" + days, cost, meta = self._fund_both(wish, w, all_dates, red_cluster, responsible) + dubbeldagar_legally_available = bool(meta["dubbeldagar_legally_available"]) + requested_strategy = "dubbeldag" if wish.wants_dubbeldag else STRATEGY_CO_LEAVE + date_counts = { + "dubbeldag": int(meta["dubbeldag_dates"]), + "co_leave": int(meta["co_leave_dates"]), + "swap": int(meta["swap_dates"]), + } + if any(date_counts.values()): + # Dominant category by DATE count; ties broken by how + # "good" the outcome is (dubbeldag > co_leave > swap) so a + # mixed window never under-reports what mostly happened. + _tier_rank = {"dubbeldag": 2, "co_leave": 1, "swap": 0} + strategy = max(date_counts, key=lambda k: (date_counts[k], _tier_rank[k])) + else: + # Nothing needed real funding (e.g. an all-free-days window) + # -- nothing degraded, report the requested strategy as-is. + strategy = requested_strategy + strategy_degraded = bool(date_counts["swap"]) or ( + requested_strategy == "dubbeldag" and date_counts["co_leave"] > 0 + ) elif responsible.mode == "swap": days, cost = self._fund_swap(wish, w, all_dates, red_cluster, responsible) strategy = "parent_swap" + requested_strategy = "parent_swap" else: parent = responsible.parents[0] resolved_child = self._resolve_wish_child(wish) @@ -1121,12 +1398,11 @@ class _Ledger: self._maybe_clamp_age4((parent.uuid, resolved_child.id), w.start) days, cost = self._fund_solo(wish, all_dates, red_cluster, parent, None) strategy = "solo" + requested_strategy = "solo" - funded_home_days = cost["free"] + cost["vacation"] + cost["sjukpenning"] + cost["lagsta"] - spendable_total = len(all_dates) - cost["free"] - funded_spendable = funded_home_days - cost["free"] - shortfall_days = spendable_total - funded_spendable - weeks_funded = round(funded_home_days / 7, 2) + shortfall_days, weeks_funded = _shortfall_and_weeks_funded( + responsible.mode, all_dates, days, cost + ) remedies = self._build_remedies(wish, w, shortfall_days) @@ -1141,6 +1417,9 @@ class _Ledger: weeks_funded=weeks_funded, shortfall_days=shortfall_days, strategy=strategy, + requested_strategy=requested_strategy, + strategy_degraded=strategy_degraded, + dubbeldagar_legally_available=dubbeldagar_legally_available, message_key=MESSAGE_KEY_WINDOW_EXPLANATION, data={ "wish_name": wish.name, @@ -1336,27 +1615,122 @@ class _Ledger: def _persisted_dates_for_wish( self, - responsible: _Responsible, + wish: LeaveWish, child_id: UUID | None, bound_start: date, bound_end: date, ) -> list[date]: """§fix — dates already persisted *before this `propose()` run* - (i.e. from a prior apply, via `_persisted_lookup` — never the - mutable `_existing_lookup`) for this wish's resolved parent(s) and - child, inside its bound. The read-only source is what keeps a fixed - wish's in-session placement from being mistaken for a flexible - wish's own history (§10.3, `TestFixedWishNeverDisplacedByFlexible`). + (i.e. from a prior apply, via `_persisted_by_wish` — never the + mutable `_existing_lookup`) for THIS wish, inside its bound. The + read-only source is what keeps a fixed wish's in-session placement + from being mistaken for a flexible wish's own history (§10.3, + `TestFixedWishNeverDisplacedByFlexible`). + + Matched by `wish_id`, deliberately NOT by the wish's currently + resolved parent(s) (§fix, decision B): a wish's prior placement must + still be found after the user reassigns its parent, so the caller + can reuse the SAME window rather than researching one from scratch + purely because a different parent has different (or no) occupied + dates — see `AllocationInput.all_existing_planned`'s docstring. """ - parent_uuids = {p.uuid for p in responsible.parents} - return sorted( - { - d - for (p_uuid, c_id, d) in self._persisted_lookup - if c_id == child_id and p_uuid in parent_uuids and bound_start <= d <= bound_end - } + dates = self._persisted_by_wish.get((wish.id, child_id), set()) + return sorted(d for d in dates if bound_start <= d <= bound_end) + + # -- §Decision C — budget-aware candidate ranking ---------------------- + + def _candidate_shortfall( + self, + wish: LeaveWish, + responsible: _Responsible, + candidate_start: date, + candidate_end: date, + ) -> int: + """§10.2 step 6, extended — how many of a candidate window's spendable + + days the resolved parent(s) could NOT actually pay for, simulated + against a throwaway copy of the live pool/vacation state. + + `_candidate_cost` alone only counts calendar days — it has no idea + whether the wish's resolved parent(s) can actually AFFORD them. A + window that is cheap by the calendar but leaves half its days + unfunded (vacation/FP already exhausted) is worse than a slightly + costlier one the parent(s) can fully pay for, so this must be + compared FIRST, with `_candidate_cost` only as the tie-break — see + `resolve_flexible_window`/`_reuse_already_funded_window`, which both + rank candidates by `(shortfall, calendar_cost, start, wish.id)`. + + Runs the SAME `_fund_solo`/`_fund_swap`/`_fund_both` funding logic + every real window goes through, against a scratch copy of + `self._pools`/`self._vacation`/`self._dubbeldagar_used` swapped in + for the duration of the call and restored in a `finally` — `propose()` + must stay read-only and deterministic (§5 test #11), so no candidate + probe may leave a trace on the real ledger, win or lose. The winning + candidate is funded for real afterwards through the ordinary + `fund_window()` path, exactly as before this fix. + """ + dates = _dates_in(candidate_start, candidate_end) + red_cluster = _red_day_cluster_dates( + candidate_start, candidate_end, self._input.holidays, self._input.day_overrides ) + probe_window = _WishWindow( + wish=wish, + anchor_year=candidate_start.year, + start=candidate_start, + end=candidate_end, + ) + + real_pools, real_vacation = self._pools, self._vacation + real_dubbeldagar_used = dict(self._dubbeldagar_used) + self._pools = { + key: _MutablePool( + p.sjukpenning_left, p.lagsta_left, p.dubbeldagar_left, p.clamped_at_b4 + ) + for key, p in real_pools.items() + } + self._vacation = { + key: _MutableVacation(v.total_days, v.saved_days_in, v.already_used, v.is_assumed) + for key, v in real_vacation.items() + } + try: + if responsible.mode == "dubbeldag": + days, cost, _meta = self._fund_both( + wish, probe_window, dates, red_cluster, responsible + ) + elif responsible.mode == "swap": + days, cost = self._fund_swap(wish, probe_window, dates, red_cluster, responsible) + else: + parent = responsible.parents[0] + resolved_child = self._resolve_wish_child(wish) + if resolved_child is not None: + self._maybe_clamp_age4((parent.uuid, resolved_child.id), candidate_start) + days, cost = self._fund_solo(wish, dates, red_cluster, parent, None) + finally: + self._pools, self._vacation = real_pools, real_vacation + self._dubbeldagar_used = real_dubbeldagar_used + + shortfall_days, _weeks_funded = _shortfall_and_weeks_funded( + responsible.mode, dates, days, cost + ) + return shortfall_days + + def _rank_candidate( + self, + wish: LeaveWish, + responsible: _Responsible, + candidate: tuple[date, date], + ) -> tuple[int, int, date, int]: + """Sort key shared by `resolve_flexible_window` and + + `_reuse_already_funded_window`: fewest unfundable days first (§Decision + C), THEN calendar cost, THEN start date, THEN wish id — the existing + priority ordering (§10.2 step 6) is otherwise unchanged. + """ + start, end = candidate + shortfall = self._candidate_shortfall(wish, responsible, start, end) + cost = _candidate_cost(start, end, self._input.holidays, self._input.day_overrides) + return (shortfall, cost, start, wish.id.int) def _reuse_already_funded_window( self, @@ -1394,9 +1768,7 @@ class _Ledger: case is what keeps a genuinely-unsatisfied year free to move (§10.3) rather than frozen to a window that no longer fits. """ - persisted_dates = self._persisted_dates_for_wish( - responsible, child_id, bound_start, bound_end - ) + persisted_dates = self._persisted_dates_for_wish(wish, child_id, bound_start, bound_end) if not persisted_dates: return None @@ -1415,14 +1787,7 @@ class _Ledger: if not candidates: return None - return min( - candidates, - key=lambda c: ( - _candidate_cost(c[0], c[1], self._input.holidays, self._input.day_overrides), - c[0], - wish.id.int, - ), - ) + return min(candidates, key=lambda c: self._rank_candidate(wish, responsible, c)) def resolve_flexible_window( self, wish: LeaveWish, anchor_year: int, bound_start: date, bound_end: date @@ -1468,12 +1833,7 @@ class _Ledger: return None winner_start, winner_end = min( - candidates, - key=lambda c: ( - _candidate_cost(c[0], c[1], self._input.holidays, self._input.day_overrides), - c[0], - wish.id.int, - ), + candidates, key=lambda c: self._rank_candidate(wish, responsible, c) ) return _WishWindow(wish=wish, anchor_year=anchor_year, start=winner_start, end=winner_end) @@ -1499,6 +1859,13 @@ class _Ledger: for r in self._build_remedies(wish, bound_window, length_days) if r.kind != REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS ] + responsible = self._resolve_parents(wish) + if responsible.mode == "dubbeldag": + requested_strategy = "dubbeldag" if wish.wants_dubbeldag else STRATEGY_CO_LEAVE + elif responsible.mode == "swap": + requested_strategy = "parent_swap" + else: + requested_strategy = "solo" return WindowOutcome( wish_id=wish.id, wish_name=wish.name, @@ -1510,6 +1877,9 @@ class _Ledger: weeks_funded=0.0, shortfall_days=length_days, strategy=STRATEGY_UNPLACED, + requested_strategy=requested_strategy, + strategy_degraded=False, + dubbeldagar_legally_available=False, message_key=MESSAGE_KEY_WINDOW_EXPLANATION, data={ "wish_name": wish.name, @@ -2028,11 +2398,18 @@ class AllocationInputLoader: rules = settings.rules pools: dict[tuple[UUID, UUID], DayPool] = {} existing_planned: list[PlannedLeaveDay] = [] + # §fix (parent-reassignment moves dates, decision B) — the FULL, + # never-`only_pinned_existing`-filtered snapshot, always populated + # regardless of the flag; see `AllocationInput.all_existing_planned`'s + # docstring. Equal to `existing_planned` when `only_pinned_existing` + # is False. + all_existing_planned: list[PlannedLeaveDay] = [] for child in child_entities: planned_for_child = await self._planned_leave_repo.list_by_workspace_child( workspace_id, child.id ) + all_existing_planned.extend(planned_for_child) if only_pinned_existing: # Hide unpinned, wish-attributed rows entirely -- see the # docstring above. Pinned rows and ad-hoc (no wish_id) rows @@ -2057,7 +2434,7 @@ class AllocationInputLoader: # matches the manual-entry convention in # scripts/seed_demo.py and `fk_scraper.py`'s # "= rules.lagsta_niva_days/2" comment). - sjuk_left, lagsta_left = default_tier_pool_share(rules) + sjuk_left, lagsta_left = default_tier_pool_share(rules, len(parent_entities)) dubbel_left = rules.max_dubbeldagar # Net out days already committed locally (planned_leave_day) @@ -2164,8 +2541,7 @@ class AllocationInputLoader: return AllocationInput( today=today, horizon_end=horizon_end, - planning_mode=ws.planning_mode, - age4_savable_cap=resolve_age4_savable_cap(ws.planning_mode, len(parents)), + age4_savable_cap=resolve_age4_savable_cap(len(parents)), children=children, parents=parents, pools=pools, @@ -2174,6 +2550,7 @@ class AllocationInputLoader: existing_planned=existing_planned, holidays=holidays_map, day_overrides=day_overrides, + all_existing_planned=all_existing_planned, ) diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index e8e6b3b..0097048 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -7,9 +7,10 @@ The headline calculation (§6.5 ARCHITECTURE.md): savable_after_B4 = min(projected_left_at_B4, CAP) # age-4 ceiling expiring_at_B4 = max(projected_left_at_B4 - CAP, 0) # LOST days -Where `CAP` is 96 (per child) normally, or 48 (per parent) for a `couple` -workspace with two parents — see `app.utils.tip_predicates. -resolve_age4_savable_cap`, the single function that decides which applies. +Where `CAP` is 96 (per child) for a genuine single parent, or 48 (per +parent, non-poolable) as soon as the child has two parents. See `app.utils. +tip_predicates.resolve_age4_savable_cap`, the single function that decides +which applies. All rule constants come from config.LeaveRules — no magic numbers. dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic. @@ -30,7 +31,11 @@ from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.repositories.workspace_repository import WorkspaceRepository from app.utils.errors import DubbeldagError, NotFoundError -from app.utils.tip_predicates import days_left_at_age_4_projection, resolve_age4_savable_cap +from app.utils.tip_predicates import ( + days_left_at_age_4_projection, + default_tier_pool_share, + resolve_age4_savable_cap, +) @dataclass @@ -47,10 +52,10 @@ class Age4Projection: projected_left_at_b4: int savable_after_b4: int # min(projected_left, savable_cap_at_b4) expiring_at_b4: int # max(projected_left - savable_cap_at_b4, 0) — LOST if not used - # The cap actually applied — 96 (per child) or 48 (per parent, couple - # workspace with 2 parents). See `resolve_age4_savable_cap`. Surfaced so - # the frontend can render "X kvar / Y sparbara" without re-deriving the - # rule itself. + # The cap actually applied — 96 (per child, single parent) or 48 (per + # parent, non-poolable, as soon as the child has 2+ parents). See + # `resolve_age4_savable_cap`. Surfaced so the frontend can render "X kvar + # / Y sparbara" without re-deriving the rule itself. savable_cap_at_b4: int @@ -79,17 +84,37 @@ class AnalyticsService: self._parents = personal_data_repo self._workspace_repo = workspace_repo + async def get_parent_count(self, workspace_id: UUID) -> int: + """Number of (non-deleted) parents on a workspace. + + Shared read so callers that need a parent count (the age-4 cap here, + `PlanningService._tier_pool_left`'s default-tier-share lookup) never + query membership differently. + """ + members = await self._parents.list_by_workspace(workspace_id) + return len(members) + + async def get_parent_count_for_child(self, child_id: UUID) -> int: + """Number of parents on the workspace that owns this child. + + Lets a caller that only has a `child_id` (no `workspace_id` in + scope) still resolve a parent count — see `PlanningService. + _tier_pool_left`, which only ever receives `(parent_uuid, child_id)`. + """ + child = await self._children.get_by_id(child_id) + if child is None: + raise NotFoundError(f"Child {child_id} not found") + return await self.get_parent_count(child.workspace_id) + async def _resolve_savable_cap(self, workspace_id: UUID) -> int: """The single call site AnalyticsService uses for the age-4 cap — - wraps `resolve_age4_savable_cap` with the DB reads it needs - (workspace planning_mode + member count) so callers never resolve - the cap any other way. + wraps `resolve_age4_savable_cap` with the DB read it needs (member + count) so callers never resolve the cap any other way. The cap is a + fact about how many parents the child has, not a workspace display + preference — see `resolve_age4_savable_cap`'s docstring. """ - workspace = await self._workspace_repo.get_by_id(workspace_id) - planning_mode = workspace.planning_mode if workspace is not None else "solo" - members = await self._parents.list_by_workspace(workspace_id) - return resolve_age4_savable_cap(planning_mode, len(members)) + return resolve_age4_savable_cap(await self.get_parent_count(workspace_id)) async def get_days_left_at_age_4( self, personal_data_uuid: UUID, child_id: UUID, workspace_id: UUID | None = None @@ -110,9 +135,15 @@ class AnalyticsService: latest = await self._leave.get_latest_by_parent(personal_data_uuid, child_id) if latest is None: - # No FK data yet — assume full entitlement with 0 used. + # No FK data yet — assume full entitlement (0 used) for this + # parent's share, via the same shared helper `AllocationInputLoader. + # load`/`PlanningService._tier_pool_left` use, so a sole parent + # (their whole-pool share) and a partnered one (half) never + # disagree with the Plan tab's own no-snapshot default (Bug A). days_used = 0 - total_days = rules.days_per_parent + parent_count = await self.get_parent_count(child.workspace_id) + sjuk_share, lagsta_share = default_tier_pool_share(rules, parent_count) + total_days = sjuk_share + lagsta_share else: days_used = latest.days_used total_days = latest.total_days diff --git a/backend/app/services/fetch_data_service.py b/backend/app/services/fetch_data_service.py index a8240a7..ddd0cd2 100644 --- a/backend/app/services/fetch_data_service.py +++ b/backend/app/services/fetch_data_service.py @@ -10,13 +10,17 @@ See ARCHITECTURE.md §2.3 for the legal/ToS gate before shipping scraping. from __future__ import annotations +import logging from abc import ABC, abstractmethod -from datetime import datetime -from uuid import UUID, uuid4 +from uuid import UUID -from app.entities.parental_leave import ParentalLeaveFetch, TierData +from app.config import settings +from app.entities.parental_leave import ParentalLeaveFetch from app.repositories.parental_leave_repository import ParentalLeaveRepository -from app.utils.errors import ValidationError +from app.repositories.personal_data_repository import PersonalDataRepository +from app.services.leave_snapshot import build_snapshot + +logger = logging.getLogger(__name__) class FetchDataService(ABC): @@ -34,60 +38,61 @@ class FetchDataService(ABC): class ManualFetchService(FetchDataService): - """Persist user-entered FK data as a snapshot with source='manual'.""" + """Persist user-entered FK data as a snapshot with source='manual'/'deduced'.""" - def __init__(self, repo: ParentalLeaveRepository) -> None: + def __init__( + self, + repo: ParentalLeaveRepository, + personal_data_repo: PersonalDataRepository | None = None, + ) -> None: self._repo = repo + # Optional: only needed to default `total_days` in `save_manual_entry` + # when the caller omits it. Existing direct-construction call sites + # (scripts/seed_demo.py, tests) that always pass an explicit + # `total_days` don't need to wire one up. + self._personal_data_repo = personal_data_repo async def save_manual_entry( self, personal_data_uuid: UUID, workspace_id: UUID, child_id: UUID, - total_days: int, - days_used: int, - reserved_days_total: int, - reserved_days_left: int, - transferable_days_left: int, + sjukpenning_left: int, + lagsta_niva_left: int, dubbeldagar_left: int, - sjukpenning_total: int, - sjukpenning_used: int, - lagsta_niva_total: int, - lagsta_niva_used: int, + reserved_days_left: int | None = None, + total_days: int | None = None, + source: str = "manual", ) -> ParentalLeaveFetch: - """Validate and persist a manual data-entry snapshot.""" - days_left = total_days - days_used - if days_left < 0: - raise ValidationError("days_used cannot exceed total_days") - if reserved_days_left > reserved_days_total: - raise ValidationError("reserved_days_left cannot exceed reserved_days_total") + """Validate and persist a manual (or deduced) data-entry snapshot. - tier_data = TierData( - sjukpenning_total=sjukpenning_total, - sjukpenning_used=sjukpenning_used, - sjukpenning_left=sjukpenning_total - sjukpenning_used, - lagsta_niva_total=lagsta_niva_total, - lagsta_niva_used=lagsta_niva_used, - lagsta_niva_left=lagsta_niva_total - lagsta_niva_used, - ) - now = datetime.now() - fetch = ParentalLeaveFetch( - id=uuid4(), + When `total_days` is omitted, it defaults to + `rules.total_days_per_child // parent_count` (480 for a sole parent, + 240 with a partner) — the number of *workspace members*, not just + this one parent, since the pool is split per how many parents the + child actually has. Forwards to the one shared derivation, + `build_snapshot`, so a manual snapshot is derived identically to a + scraped one. + """ + if total_days is None: + if self._personal_data_repo is None: + raise ValueError( + "total_days was omitted but ManualFetchService has no " + "personal_data_repo to resolve a parent count from" + ) + members = await self._personal_data_repo.list_by_workspace(workspace_id) + parent_count = max(len(members), 1) + total_days = settings.rules.total_days_per_child // parent_count + fetch = build_snapshot( personal_data_uuid=personal_data_uuid, workspace_id=workspace_id, child_id=child_id, - source="manual", - fetched_at=now, + source=source, total_days=total_days, - days_used=days_used, - days_left=days_left, - reserved_days_total=reserved_days_total, - reserved_days_left=reserved_days_left, - transferable_days_left=transferable_days_left, + sjukpenning_left=sjukpenning_left, + lagsta_niva_left=lagsta_niva_left, dubbeldagar_left=dubbeldagar_left, - tier_data=tier_data, - created_at=now, - updated_at=now, + reserved_days_left=reserved_days_left, ) return await self._repo.create(fetch) @@ -100,3 +105,62 @@ class ManualFetchService(FetchDataService): self, personal_data_uuid: UUID, child_id: UUID ) -> list[ParentalLeaveFetch]: return await self._repo.list_history(personal_data_uuid, child_id) + + +async def persist_partner_deduction( + *, + personal_data_repo: PersonalDataRepository, + parental_leave_repo: ParentalLeaveRepository, + workspace_id: UUID, + child_id: UUID, + caller_personal_data_uuid: UUID, + total_days: int, + sjukpenning_left: int, + lagsta_niva_left: int, + dubbeldagar_left: int, +) -> None: + """Persist a 'deduced' snapshot for the caller's partner, if there is one. + + Called by `app.routers.fetch_data.start_fetch`'s `_persist` after the FK + scraper deduces the other parent's balance from the family panel (see + `app.scrapers.fk_scraper._deduce_partner`). Takes plain fields rather + than the scraper's `PartnerDeduction` dataclass to avoid a router/scraper + import cycle (`fk_scraper` already imports from this module) — the + router unpacks it before calling in. + + A no-op (logged, never raised) unless the workspace has exactly one + other non-deleted member: with zero, there's no one to persist for; with + two or more, which member is "the partner" is ambiguous and guessing + wrong would silently corrupt someone else's data. Any persistence + failure here is logged and swallowed — the caller's own snapshot is + already saved by the time this runs, and a failure here must not fail + the scraper session. + """ + try: + members = await personal_data_repo.list_by_workspace(workspace_id) + others = [m for m in members if m.uuid != caller_personal_data_uuid] + if len(others) != 1: + logger.info( + "skipping partner-deduction persist: workspace has %d other member(s)", + len(others), + ) + return + partner = others[0] + fetch = build_snapshot( + personal_data_uuid=partner.uuid, + workspace_id=workspace_id, + child_id=child_id, + source="deduced", + total_days=total_days, + sjukpenning_left=sjukpenning_left, + lagsta_niva_left=lagsta_niva_left, + dubbeldagar_left=dubbeldagar_left, + ) + await parental_leave_repo.create(fetch) + except Exception as exc: + logger.warning( + "failed to persist partner-deduction snapshot for workspace=%s child=%s: %s", + workspace_id, + child_id, + exc, + ) diff --git a/backend/app/services/leave_snapshot.py b/backend/app/services/leave_snapshot.py new file mode 100644 index 0000000..d97a008 --- /dev/null +++ b/backend/app/services/leave_snapshot.py @@ -0,0 +1,187 @@ +"""Shared derivation for a `ParentalLeaveFetch` snapshot. + +Both the FK scraper (`app.scrapers.fk_scraper`) and manual entry +(`app.services.fetch_data_service.ManualFetchService`) hand a parent's raw +"days left" numbers to `build_snapshot`, which is the ONE place that derives +everything else (tier totals, days used, reserved/transferable split) and +validates the result. Neither caller is allowed to re-derive any of this +itself — see PLANNING_ENGINE_SPEC.md and the "Bug A" note in +`app.utils.tip_predicates.default_tier_pool_share` for what happens when two +call sites disagree. + +Domain: a child has `rules.total_days_per_child` days total (390 +sjukpenningnivå + 90 lägstanivå). Split across two parents each gets half +(195 + 45 = 240); `rules.reserved_days_per_parent` (90) of each parent's +share is reserved (non-transferable). A sole parent gets the full 480 +(390 + 90). `total_days` here is always the ONE PARENT's total (240 or 480), +never the per-child combined figure. +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from app.config import settings +from app.entities.parental_leave import ParentalLeaveFetch, TierData +from app.utils.errors import ValidationError + +_VALID_SOURCES = ("scraped", "manual", "deduced") + + +def build_snapshot( + *, + personal_data_uuid: UUID, + workspace_id: UUID, + child_id: UUID, + source: str, + total_days: int, + sjukpenning_left: int, + lagsta_niva_left: int, + dubbeldagar_left: int, + reserved_days_left: int | None = None, + fetched_at: datetime | None = None, +) -> ParentalLeaveFetch: + """Derive and validate a full `ParentalLeaveFetch` snapshot. + + `reserved_days_left`, when omitted, is derived assuming transferable + (non-reserved) days are always consumed before reserved ones — the same + assumption the FK scraper's page reading has always made, since FK's UI + doesn't expose the reserved/transferable split directly. When the caller + already knows the reserved split (e.g. a future, more detailed manual + form), passing it directly skips that assumption. + """ + if source not in _VALID_SOURCES: + raise ValidationError( + f"source must be one of {_VALID_SOURCES}, got {source!r}", + message_key="parentalLeave.invalidSource", + data={"source": source}, + ) + + rules = settings.rules + + # -- Tier totals (standard FK allocation for this parent) -- + # `total_days` is this ONE parent's total (240 with a partner, 480 solo); + # split it into tiers using the same 390:90 ratio as the whole-child pool + # so 240 -> (195, 45) and 480 -> (390, 90) without hardcoding either. + lagsta_niva_total = ( + rules.lagsta_niva_days * total_days // (rules.sjukpenning_days + rules.lagsta_niva_days) + ) + sjukpenning_total = total_days - lagsta_niva_total + + days_left = sjukpenning_left + lagsta_niva_left + days_used = total_days - days_left + sjukpenning_used = sjukpenning_total - sjukpenning_left + lagsta_niva_used = lagsta_niva_total - lagsta_niva_left + + # -- Reserved / transferable split -- + # Reserved days cannot be transferred to the other parent; the + # long-standing assumption (matches FK's own "spend transferable first") + # is that transferable days are used up before any reserved day is. + reserved_days_total = rules.reserved_days_per_parent + if reserved_days_left is None: + transferable_total = total_days - reserved_days_total + transferable_used = min(days_used, transferable_total) + reserved_used = days_used - transferable_used + reserved_days_left = reserved_days_total - reserved_used + transferable_days_left = transferable_total - transferable_used + else: + transferable_days_left = days_left - reserved_days_left + + # -- Validate before returning -- + if days_left < 0: + raise ValidationError( + f"days_left is negative ({days_left}) — FK data inconsistency", + message_key="parentalLeave.negativeDaysLeft", + data={"days_left": days_left}, + ) + if sjukpenning_left < 0: + raise ValidationError( + f"sjukpenning_left is negative ({sjukpenning_left})", + message_key="parentalLeave.negativeSjukpenningLeft", + data={"sjukpenning_left": sjukpenning_left}, + ) + if sjukpenning_left > sjukpenning_total: + raise ValidationError( + f"sjukpenning_left ({sjukpenning_left}) exceeds sjukpenning_total " + f"({sjukpenning_total})", + message_key="parentalLeave.sjukpenningExceedsTotal", + data={"sjukpenning_left": sjukpenning_left, "sjukpenning_total": sjukpenning_total}, + ) + if lagsta_niva_left < 0: + raise ValidationError( + f"lagsta_niva_left is negative ({lagsta_niva_left})", + message_key="parentalLeave.negativeLagstaNivaLeft", + data={"lagsta_niva_left": lagsta_niva_left}, + ) + if lagsta_niva_left > lagsta_niva_total: + raise ValidationError( + f"lagsta_niva_left ({lagsta_niva_left}) exceeds lagsta_niva_total " + f"({lagsta_niva_total})", + message_key="parentalLeave.lagstaNivaExceedsTotal", + data={"lagsta_niva_left": lagsta_niva_left, "lagsta_niva_total": lagsta_niva_total}, + ) + if reserved_days_left < 0: + raise ValidationError( + f"reserved_days_left is negative ({reserved_days_left})", + message_key="parentalLeave.negativeReservedDaysLeft", + data={"reserved_days_left": reserved_days_left}, + ) + if reserved_days_left > reserved_days_total: + raise ValidationError( + f"reserved_days_left ({reserved_days_left}) > reserved_days_total " + f"({reserved_days_total})", + message_key="parentalLeave.reservedExceedsTotal", + data={ + "reserved_days_left": reserved_days_left, + "reserved_days_total": reserved_days_total, + }, + ) + if transferable_days_left < 0: + raise ValidationError( + f"transferable_days_left is negative ({transferable_days_left})", + message_key="parentalLeave.negativeTransferableDaysLeft", + data={"transferable_days_left": transferable_days_left}, + ) + if dubbeldagar_left < 0: + raise ValidationError( + f"dubbeldagar_left is negative ({dubbeldagar_left})", + message_key="parentalLeave.negativeDubbeldagarLeft", + data={"dubbeldagar_left": dubbeldagar_left}, + ) + if dubbeldagar_left > rules.max_dubbeldagar: + raise ValidationError( + f"dubbeldagar_left ({dubbeldagar_left}) exceeds rules.max_dubbeldagar " + f"({rules.max_dubbeldagar})", + message_key="parentalLeave.dubbeldagarExceedsMax", + data={"dubbeldagar_left": dubbeldagar_left, "max_dubbeldagar": rules.max_dubbeldagar}, + ) + + now = fetched_at or datetime.now() + tier_data = TierData( + sjukpenning_total=sjukpenning_total, + sjukpenning_used=sjukpenning_used, + sjukpenning_left=sjukpenning_left, + lagsta_niva_total=lagsta_niva_total, + lagsta_niva_used=lagsta_niva_used, + lagsta_niva_left=lagsta_niva_left, + ) + + return ParentalLeaveFetch( + id=uuid4(), + personal_data_uuid=personal_data_uuid, + workspace_id=workspace_id, + child_id=child_id, + source=source, + fetched_at=now, + total_days=total_days, + days_used=days_used, + days_left=days_left, + reserved_days_total=reserved_days_total, + reserved_days_left=reserved_days_left, + transferable_days_left=transferable_days_left, + dubbeldagar_left=dubbeldagar_left, + tier_data=tier_data, + created_at=now, + updated_at=now, + ) diff --git a/backend/app/services/leave_wish_service.py b/backend/app/services/leave_wish_service.py index ebfbebd..b3104f8 100644 --- a/backend/app/services/leave_wish_service.py +++ b/backend/app/services/leave_wish_service.py @@ -49,6 +49,7 @@ class LeaveWishService: parent_mode: str = "either", parent_personal_data_uuid: UUID | None = None, window_mode: str = "fixed", + wants_dubbeldag: bool = False, ) -> LeaveWish: _validate( window_start_month=window_start_month, @@ -63,6 +64,7 @@ class LeaveWishService: parent_mode=parent_mode, parent_personal_data_uuid=parent_personal_data_uuid, window_mode=window_mode, + wants_dubbeldag=wants_dubbeldag, ) now = datetime.now() entity = LeaveWish( @@ -84,6 +86,7 @@ class LeaveWishService: parent_mode=parent_mode, parent_personal_data_uuid=parent_personal_data_uuid, window_mode=window_mode, + wants_dubbeldag=wants_dubbeldag, created_at=now, updated_at=now, ) @@ -124,6 +127,7 @@ class LeaveWishService: parent_mode: str | None = None, parent_personal_data_uuid: UUID | None = None, window_mode: str | None = None, + wants_dubbeldag: bool | None = None, _clear_child_id: bool = False, _clear_last_year: bool = False, _clear_parent_personal_data_uuid: bool = False, @@ -192,6 +196,8 @@ class LeaveWishService: entity.parent_personal_data_uuid = parent_personal_data_uuid if window_mode is not None: entity.window_mode = window_mode + if wants_dubbeldag is not None: + entity.wants_dubbeldag = wants_dubbeldag _validate( window_start_month=entity.window_start_month, @@ -206,6 +212,7 @@ class LeaveWishService: parent_mode=entity.parent_mode, parent_personal_data_uuid=entity.parent_personal_data_uuid, window_mode=entity.window_mode, + wants_dubbeldag=entity.wants_dubbeldag, ) return await self._repo.update(leave_wish_id, entity) @@ -250,6 +257,7 @@ def _validate( parent_mode: str, parent_personal_data_uuid: UUID | None, window_mode: str, + wants_dubbeldag: bool = False, ) -> None: if recurrence not in _VALID_RECURRENCE: raise ValidationError(f"recurrence must be one of {_VALID_RECURRENCE}") @@ -257,6 +265,8 @@ def _validate( raise ValidationError(f"parent_mode must be one of {_VALID_PARENT_MODE}") if window_mode not in _VALID_WINDOW_MODE: raise ValidationError(f"window_mode must be one of {_VALID_WINDOW_MODE}") + if wants_dubbeldag and parent_mode != "both": + raise ValidationError("wants_dubbeldag may only be set when parent_mode is 'both'") _validate_month_day(window_start_month, window_start_day, label="window_start") _validate_month_day(window_end_month, window_end_day, label="window_end") diff --git a/backend/app/services/planning_service.py b/backend/app/services/planning_service.py index 52d6a81..ab146c1 100644 --- a/backend/app/services/planning_service.py +++ b/backend/app/services/planning_service.py @@ -8,15 +8,27 @@ from dateutil.relativedelta import relativedelta from app.config import settings from app.entities.planned_leave import PlannedLeaveDay +from app.repositories.leave_wish_repository import LeaveWishRepository from app.repositories.parental_leave_repository import ParentalLeaveRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork from app.repositories.vacation_days_repository import VacationDaysRepository -from app.services.allocation_service import PlanningEngineService, PlanProposal +from app.services.allocation_service import PlanningEngineService, PlanProposal, WindowOutcome from app.services.analytics_service import AnalyticsService from app.utils.errors import DubbeldagError, NotFoundError, ValidationError from app.utils.tip_predicates import default_tier_pool_share +# The two day-type buckets a persisted day can belong to for eviction +# purposes -- see `EvictionCandidate`/`ResourceShortage` below. Exhaustive and +# mutually exclusive with `_PERSISTABLE_DAY_TYPES` (defined below). +_PL_DAY_TYPES = {"sjukpenning", "lagsta"} + +# `sync_plan`'s two caller-authorized eviction strategies (§ eviction-preview +# feature). Anything else (including `None`, the default) means "do nothing": +# never evict without an explicit instruction. +EVICTION_CHOICE_DROP_FUTURE_PL = "drop_future_pl" +EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR = "drop_latest_vacation_this_year" + # Day types that can actually be persisted as a `planned_leave_day` row. A # proposal may also contain 'free' entries (automatic weekend/red-day # non-workdays, see allocation_service.ProposedDay) purely for explainability @@ -126,11 +138,71 @@ class PlanChange: new_wish_id: UUID | None +@dataclass(frozen=True) +class EvictionCandidate: + """One previously-persisted day a fresh `sync_plan` would otherwise + + silently delete because a *different* wish's edit consumed the shared + pool ahead of it (see `PlanningService._genuinely_evicted_rows` for the + "genuinely evicted" vs. "this wish's own window shrank" distinction -- + only the former ever appears here). Carries everything the frontend + needs to name the day without the backend pre-rendering prose: + `wish_name` is resolved from the *pre-write* `LeaveWish` row (`None` + when the service has no `LeaveWishRepository` wired, or the wish's name + can't be found -- degrades gracefully rather than failing the preview). + """ + + date: date + parent_personal_data_uuid: UUID + child_id: UUID + day_type: str + is_dubbeldag: bool + wish_id: UUID + wish_name: str | None + + +@dataclass(frozen=True) +class ResourceShortage: + """One exhausted resource implicated by an eviction preview. + + Names precisely what ran out (§3 of the eviction-preview requirement): + `resource_kind == 'parental_leave'` carries `tier` + `child_id` (a + specific sjukpenning/lagsta pool for one parent+child); `resource_kind + == 'vacation'` carries `year` instead (`tier`/`child_id` are `None` -- + vacation is budgeted per parent+year, not per child). Never a + pre-rendered string -- the frontend composes the warning copy itself. + """ + + resource_kind: str # 'parental_leave' | 'vacation' + parent_personal_data_uuid: UUID + tier: str | None + child_id: UUID | None + year: int | None + days_evicted: int + + +@dataclass(frozen=True) +class EvictionPreview: + """What a `sync_plan` call would silently delete, and why -- always + + present on `PlanSyncResult` (empty/inert when nothing would be + evicted, so the common case costs the caller nothing extra to check). + """ + + would_evict: bool + candidates: list[EvictionCandidate] = field(default_factory=list) + shortages: list[ResourceShortage] = field(default_factory=list) + + +_NO_EVICTION_PREVIEW = EvictionPreview(would_evict=False) + + @dataclass(frozen=True) class PlanSyncResult: proposal: PlanProposal apply_result: PlanApplyResult changes: list[PlanChange] = field(default_factory=list) + eviction_preview: EvictionPreview = _NO_EVICTION_PREVIEW class PlanningService: @@ -141,6 +213,7 @@ class PlanningService: parental_leave_repo: ParentalLeaveRepository, vacation_repo: VacationDaysRepository, planning_engine_service: PlanningEngineService | None = None, + leave_wish_repo: LeaveWishRepository | None = None, uow: UnitOfWork | None = None, ) -> None: self._repo = repo @@ -152,6 +225,13 @@ class PlanningService: # ever use the per-day CRUD/`apply_proposed_days` paths don't need to # wire up a `PlanningEngineService` they'll never use. self._engine = planning_engine_service + # Only needed by `sync_plan`'s eviction preview, to resolve a stale + # row's `wish_id` back to a human-readable name (`EvictionCandidate. + # wish_name`) -- optional for the same reason `_engine` is: existing + # direct-construction call sites that never hit the eviction path + # don't need to wire one up. `wish_name` degrades to `None` when this + # is unset, never raises. + self._leave_wish_repo = leave_wish_repo # Defaults to a no-op so pre-existing direct-construction call sites # (scripts/seed_demo.py, test fixtures) keep today's per-write-commit # behavior; `app.dependencies.get_planning_service` always wires a @@ -579,7 +659,12 @@ class PlanningService: # let a sync that frees N days and re-spends N days read the freed days # as still-spent net-new demand and reject the whole batch. - async def sync_plan(self, workspace_id: UUID, today: date | None = None) -> PlanSyncResult: + async def sync_plan( + self, + workspace_id: UUID, + today: date | None = None, + eviction_choice: str | None = None, + ) -> PlanSyncResult: """Propose, persist, and diff — the single call the direct- manipulation calendar UX needs (no more separate propose/apply @@ -587,9 +672,45 @@ class PlanningService: read here, before any write, compared against what's actually persisted afterwards — never reconstructed from the client side or guessed from `apply_proposed_days`'s own (coarser) counters. + + **Eviction preview/resolution.** Not every stale key (see the module + comment above) is a silent-data-loss bug: a wish the user just + edited to claim fewer/different dates sheds its OWN old rows as + 'removed' `changes` — expected, and applied immediately with no + friction. The bug this parameter fixes is different: a stale row + whose wish was NOT touched, still wants exactly what it always + wanted, but lost the priority race for a shared pool to some OTHER + wish's edit. `_genuinely_evicted_rows` tells the two apart by + checking, for each stale row, whether ITS OWN wish's fresh + `WindowOutcome` for that date now reports `shortfall_days > 0` — the + wish still wants that capacity and didn't get it, as opposed to no + longer wanting it at all. + + When nothing is genuinely evicted, this call behaves exactly as + before (no extra round trip, no extra friction — requirement 5). + When something would be genuinely evicted: + - `eviction_choice is None` (the default): nothing is written at + all, not even the harmless parts of the batch — "never evict + without an explicit instruction". The returned `eviction_preview` + names what ran out (`ResourceShortage`) and exactly which days + would go (`EvictionCandidate`), so the caller can ask the user. + - `eviction_choice` names one of the two caller-authorized + strategies (`EVICTION_CHOICE_DROP_FUTURE_PL` / + `EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR`): only the + genuinely-evicted rows matching that category (future PL days / + this calendar year's vacation days) are added to `delete_keys`, + on top of the always-deleted self-caused stale rows. If that + doesn't free enough (e.g. the real shortage was the OTHER + category), `apply_proposed_days`' own balance validation rejects + the whole batch exactly as it would have anyway — atomic, nothing + partially written. + + Pinned days are structurally excluded from `stale_keys` itself (see + below), so no eviction strategy can ever reach one. """ if self._engine is None: raise ValidationError("PlanningService.sync_plan requires a PlanningEngineService") + resolved_today = today or date.today() # One unit of work spans the pre-write snapshot through the post-write # re-read, not just `apply_proposed_days`'s own writes — otherwise a @@ -640,9 +761,44 @@ class PlanningService: for key, row in before_by_key.items() if not row.is_pinned and row.wish_id is not None and key not in batch_keys } + stale_rows = [before_by_key[key] for key in stale_keys] + + # Genuinely evicted: a stale row whose OWN wish still wants that + # exact capacity (fresh `shortfall_days > 0`) but lost it to a + # different wish's edit — see this method's docstring. Every + # other stale row is "self-caused" (the wish itself moved/ + # shrank/was retargeted) and is always deleted, exactly like + # before this parameter existed. + evicted_rows = self._genuinely_evicted_rows(stale_rows, proposal.outcomes) + eviction_preview = await self._build_eviction_preview(workspace_id, evicted_rows) + + if not evicted_rows: + delete_keys = stale_keys + elif eviction_choice is None: + return PlanSyncResult( + proposal=proposal, + apply_result=PlanApplyResult( + accepted=False, + rejections=[ + Rejection( + message_key="planSync.rejection.evictionChoiceRequired", + data={"count": len(evicted_rows)}, + ) + ], + ), + changes=[], + eviction_preview=eviction_preview, + ) + else: + evicted_keys = {self._row_key(row) for row in evicted_rows} + self_caused_keys = stale_keys - evicted_keys + authorized_keys = self._eviction_delete_keys( + evicted_rows, eviction_choice, resolved_today + ) + delete_keys = self_caused_keys | authorized_keys apply_result = await self.apply_proposed_days( - workspace_id=workspace_id, days=batch, replace=True, delete_keys=stale_keys + workspace_id=workspace_id, days=batch, replace=True, delete_keys=delete_keys ) changes: list[PlanChange] = [] @@ -721,7 +877,146 @@ class PlanningService: ) ) - return PlanSyncResult(proposal=proposal, apply_result=apply_result, changes=changes) + return PlanSyncResult( + proposal=proposal, + apply_result=apply_result, + changes=changes, + eviction_preview=eviction_preview, + ) + + @staticmethod + def _row_key(row: PlannedLeaveDay) -> tuple[UUID, UUID, date]: + return (row.child_id, row.parent_personal_data_uuid, row.date) + + @staticmethod + def _genuinely_evicted_rows( + stale_rows: list[PlannedLeaveDay], outcomes: list[WindowOutcome] + ) -> list[PlannedLeaveDay]: + """Split "genuinely evicted" (blame a different wish) from + + "self-caused" (the row's own wish moved/shrank and no longer wants + it) — see `sync_plan`'s docstring. A stale row is genuinely evicted + only when its own `wish_id` has a fresh `WindowOutcome` covering + that exact date (`start_date <= row.date <= end_date`) that reports + `shortfall_days > 0`: the wish still wants that day and didn't get + it. No matching outcome at all (the wish's window moved away from + this date entirely) is always self-caused, never eviction. + """ + outcomes_by_wish: dict[UUID, list[WindowOutcome]] = {} + for outcome in outcomes: + outcomes_by_wish.setdefault(outcome.wish_id, []).append(outcome) + + evicted: list[PlannedLeaveDay] = [] + for row in stale_rows: + if row.wish_id is None: + continue # defensive: stale_keys already excludes these + matching = [ + o + for o in outcomes_by_wish.get(row.wish_id, []) + if o.start_date <= row.date <= o.end_date + ] + if any(o.shortfall_days > 0 for o in matching): + evicted.append(row) + return evicted + + async def _build_eviction_preview( + self, workspace_id: UUID, evicted_rows: list[PlannedLeaveDay] + ) -> EvictionPreview: + """Shape `evicted_rows` into the (candidates, shortages) pair the + + frontend renders the warning + day list from. Deterministic + ordering (date descending, tie-broken on parent/child/day_type) + regardless of the set-iteration order `evicted_rows` arrived in. + """ + if not evicted_rows: + return _NO_EVICTION_PREVIEW + + wish_by_id: dict[UUID, str] = {} + if self._leave_wish_repo is not None: + wishes = await self._leave_wish_repo.get_by_workspace(workspace_id) + wish_by_id = {w.id: w.name for w in wishes} + + ordered = sorted( + evicted_rows, + key=lambda r: ( + r.date, + str(r.parent_personal_data_uuid), + str(r.child_id), + r.day_type, + ), + reverse=True, + ) + + candidates = [ + EvictionCandidate( + date=row.date, + parent_personal_data_uuid=row.parent_personal_data_uuid, + child_id=row.child_id, + day_type=row.day_type, + is_dubbeldag=row.is_dubbeldag, + wish_id=row.wish_id, + wish_name=wish_by_id.get(row.wish_id), + ) + for row in ordered + # `evicted_rows` structurally always has a non-null `wish_id` + # (`_genuinely_evicted_rows` skips ad-hoc rows) -- this filter is + # defense-in-depth, and narrows the type for the constructor call + # below rather than needing a `# type: ignore`. + if row.wish_id is not None + ] + + shortage_counts: dict[tuple[str, UUID, str | None, UUID | None, int | None], int] = {} + shortage_order: list[tuple[str, UUID, str | None, UUID | None, int | None]] = [] + for row in ordered: + if row.day_type in _PL_DAY_TYPES: + key = ( + "parental_leave", + row.parent_personal_data_uuid, + row.day_type, + row.child_id, + None, + ) + else: + key = ("vacation", row.parent_personal_data_uuid, None, None, row.date.year) + if key not in shortage_counts: + shortage_order.append(key) + shortage_counts[key] = shortage_counts.get(key, 0) + 1 + + shortages = [ + ResourceShortage( + resource_kind=key[0], + parent_personal_data_uuid=key[1], + tier=key[2], + child_id=key[3], + year=key[4], + days_evicted=shortage_counts[key], + ) + for key in shortage_order + ] + + return EvictionPreview(would_evict=True, candidates=candidates, shortages=shortages) + + @staticmethod + def _eviction_delete_keys( + evicted_rows: list[PlannedLeaveDay], eviction_choice: str, today: date + ) -> set[tuple[UUID, UUID, date]]: + """The subset of `evicted_rows` the caller's chosen strategy authorizes + + deleting — never more than what's genuinely evicted, and never a + pinned row (structurally impossible here: `evicted_rows` is always a + subset of `stale_keys`, which already excludes pinned rows). + """ + if eviction_choice == EVICTION_CHOICE_DROP_FUTURE_PL: + authorized = [ + r for r in evicted_rows if r.day_type in _PL_DAY_TYPES and r.date >= today + ] + elif eviction_choice == EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR: + authorized = [ + r for r in evicted_rows if r.day_type == "vacation" and r.date.year == today.year + ] + else: + authorized = [] + return {PlanningService._row_key(r) for r in authorized} async def _validate_dubbeldagar_batch( self, @@ -835,8 +1130,10 @@ class PlanningService: # No FK snapshot yet: default this parent's share via the shared # helper — see `default_tier_pool_share` docstring (Bug A fix: # this and `AllocationInputLoader.load` used to disagree, 390/90 - # vs. 195/45). - sjuk_total_left, lagsta_total_left = default_tier_pool_share(rules) + # vs. 195/45). Parent count follows the child's actual workspace + # (solo parent gets the full pool, not half of it). + parent_count = await self._analytics.get_parent_count_for_child(child_id) + sjuk_total_left, lagsta_total_left = default_tier_pool_share(rules, parent_count) committed_sjuk = await self._repo.count_by_parent_type(parent_uuid, child_id, "sjukpenning") committed_lagsta = await self._repo.count_by_parent_type(parent_uuid, child_id, "lagsta") diff --git a/backend/app/services/tips_service.py b/backend/app/services/tips_service.py index 10ae01d..89b1d33 100644 --- a/backend/app/services/tips_service.py +++ b/backend/app/services/tips_service.py @@ -41,7 +41,6 @@ from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.repositories.tip_dismissal_repository import TipDismissalRepository from app.repositories.vacation_days_repository import VacationDaysRepository -from app.repositories.workspace_repository import WorkspaceRepository from app.utils.errors import NotFoundError from app.utils.tip_predicates import ( age4_pooling_loss, @@ -91,10 +90,6 @@ class TipsContext: vacation_by_parent: dict[UUID, list[VacationDays]] leave_wishes: list[LeaveWish] today: date # injected for deterministic testing - # 'solo' | 'couple' — see `app.utils.tip_predicates.resolve_age4_savable_cap`. - # Defaulted for backward compatibility with call sites/tests that don't - # care about the age-4 cap split. - workspace_planning_mode: str = "solo" # Type alias for a rule function. @@ -161,7 +156,7 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None: return None child = ctx.children[0] fourth_bday = child.birth_date + _relativedelta(years=4) - savable_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents)) + savable_cap = resolve_age4_savable_cap(len(ctx.parents)) for parent in ctx.parents: leave = ctx.leave_by_parent.get(parent.uuid) if leave is None: @@ -192,10 +187,14 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None: async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None: - """Warn a couple when one parent is over the per-parent age-4 cap while - the other has unused headroom — days that could have been pooled but - won't be, because `resolve_age4_savable_cap` splits the ceiling per - parent once a workspace is in `couple` mode (see `age4_pooling_loss`). + """Warn a family when one parent is over the per-parent age-4 cap while + + another has unused headroom — days that could have been pooled but won't + be, because `resolve_age4_savable_cap` splits the ceiling per parent as + soon as the child has 2+ parents (see `age4_pooling_loss`). This fires + for any 2+-parent family — the per-parent cut (and the pooling loss it + can cause) is a legal fact about the child having two parents, purely a + function of parent count. Advice order is the substance of this tip: taking more leave before the birthday converts entitlement into actual time at home (strictly @@ -207,7 +206,7 @@ async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None: exact transfer figure can't be known, so the tip degrades to a hedged "if you both have days left" phrasing instead of guessing a number. """ - if ctx.workspace_planning_mode != "couple" or not ctx.children: + if len(ctx.parents) < 2 or not ctx.children: return None child = ctx.children[0] @@ -215,7 +214,7 @@ async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None: return None # first couple of years: too early to usefully act on this fourth_bday = child.birth_date + _relativedelta(years=4) - per_parent_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents)) + per_parent_cap = resolve_age4_savable_cap(len(ctx.parents)) per_child_cap = settings.rules.max_savable_days_at_age_4 projected_left_by_parent: dict[UUID, int] = {} @@ -358,7 +357,7 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None: fourth_bday = child.birth_date + _relativedelta(years=4) today = ctx.today window_end = today + timedelta(weeks=4) - savable_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents)) + savable_cap = resolve_age4_savable_cap(len(ctx.parents)) for parent in ctx.parents: parent_window_days = [ @@ -604,7 +603,6 @@ class TipsService: pd_repo: PersonalDataRepository, vacation_repo: VacationDaysRepository, leave_wish_repo: LeaveWishRepository, - workspace_repo: WorkspaceRepository, dismissal_repo: TipDismissalRepository, ) -> None: self._leave = leave_repo @@ -613,7 +611,6 @@ class TipsService: self._parents = pd_repo self._vacation = vacation_repo self._leave_wishes = leave_wish_repo - self._workspace_repo = workspace_repo self._dismissals = dismissal_repo async def get_tips(self, workspace_id: UUID) -> list[Tip]: @@ -725,9 +722,6 @@ class TipsService: personal_data_uuid=pd.uuid, workspace_id=workspace_id ) - ws = await self._workspace_repo.get_by_id(workspace_id) - planning_mode = ws.planning_mode if ws else "solo" - workspace_ctx = TipsContext( workspace_id=workspace_id, children=[], @@ -737,7 +731,6 @@ class TipsService: vacation_by_parent=vacation_by_parent, leave_wishes=leave_wishes, today=today, - workspace_planning_mode=planning_mode, ) child_contexts: list[TipsContext] = [] @@ -758,7 +751,6 @@ class TipsService: vacation_by_parent=vacation_by_parent, leave_wishes=leave_wishes, today=today, - workspace_planning_mode=planning_mode, ) ) diff --git a/backend/app/services/workspace_service.py b/backend/app/services/workspace_service.py index d1666a8..b84cfa3 100644 --- a/backend/app/services/workspace_service.py +++ b/backend/app/services/workspace_service.py @@ -82,16 +82,10 @@ class WorkspaceService: self, workspace_id: UUID, name: str | None = None, - planning_mode: str | None = None, ) -> Workspace: ws = await self.get_workspace(workspace_id) if name is not None: ws.name = name - if planning_mode is not None: - valid_modes = {"solo", "couple"} - if planning_mode not in valid_modes: - raise ValidationError(f"planning_mode must be one of {valid_modes}") - ws.planning_mode = planning_mode return await self._ws.update(workspace_id, ws) async def list_members(self, workspace_id: UUID) -> list[PersonalData]: @@ -99,9 +93,21 @@ class WorkspaceService: return await self._pd.list_by_workspace(workspace_id) async def add_placeholder_parent(self, workspace_id: UUID, display_name: str) -> PersonalData: - """Create a parent profile without a login (solo planning for a partner).""" + """Create a parent profile without a login (solo planning for a partner). + + Capped at 2 parents per workspace (a child has exactly two legal + parents in this model) — mirrors the same cap enforced on the + invitation-redemption path (`redeem_invitation`). + """ _validate_display_name(display_name) await self.get_workspace(workspace_id) # verify workspace exists + existing_members = await self._pd.list_by_workspace(workspace_id) + if len(existing_members) >= 2: + raise ValidationError( + "This workspace already has its maximum number of parents", + message_key="workspace.parents.add.maxReached", + data={}, + ) pd = PersonalData( uuid=uuid4(), workspace_id=workspace_id, diff --git a/backend/app/utils/tip_predicates.py b/backend/app/utils/tip_predicates.py index 5e81f35..8640f63 100644 --- a/backend/app/utils/tip_predicates.py +++ b/backend/app/utils/tip_predicates.py @@ -32,36 +32,40 @@ def weekly_intensity(planned_days: list[PlannedLeaveDay], period_weeks: float) - return len(fp_days) / period_weeks -def default_tier_pool_share(rules: LeaveRules) -> tuple[int, int]: +def default_tier_pool_share(rules: LeaveRules, parent_count: int) -> tuple[int, int]: """The per-parent (sjukpenning_left, lagsta_left) assumed when no FK snapshot exists yet. `rules.sjukpenning_days`/`lagsta_niva_days` are the WHOLE CHILD's combined - tier totals (390 + 90), split across both parents — halving them gives a - single parent's default share. Shared by `AllocationInputLoader.load` and + tier totals (390 + 90). A sole parent (`parent_count <= 1`) gets the full + pool; with a partner (`parent_count >= 2`) it's split in half per parent + (195 + 45) exactly as FK does. Shared by `AllocationInputLoader.load` and `PlanningService._tier_pool_left` so they can never derive it differently (see "Bug A": they once did, 390/90 in one place vs. 195/45 in the other). """ + if parent_count <= 1: + return rules.sjukpenning_days, rules.lagsta_niva_days return rules.sjukpenning_days // 2, rules.lagsta_niva_days // 2 -def resolve_age4_savable_cap(planning_mode: str, parent_count: int) -> int: +def resolve_age4_savable_cap(parent_count: int) -> int: """Single source of truth for which age-4 savable-days ceiling applies. - `rules.max_savable_days_at_age_4` (96) is the per-CHILD ceiling. When a - child actually has two parents in a workspace explicitly flagged - `planning_mode == 'couple'`, each parent's own balance is capped at - `rules.max_savable_days_per_parent_at_age_4` (48) instead — 96 each would - let the family save 192 combined, double the legal figure. + `rules.max_savable_days_at_age_4` (96) is the per-CHILD ceiling, pooled + across however many parents the child legally has. The per-parent cut is + strictly **non-poolable**: each parent's own savable balance is capped at + `rules.max_savable_days_per_parent_at_age_4` (48) whenever the child has + two (or more) parents — 96 each would let the family save 192 combined, + double the legal figure. A genuine single parent keeps the full 96. - Requiring both conditions (couple mode AND >=2 parents) is a safety belt: - a workspace flagged 'couple' that only has one member so far still gets - the per-child (96) figure, since there is no second parent to divide it - with. `AnalyticsService` and `allocation_service`'s `_Ledger` both call + This is a fact about the family (how many parents the child has), not a + workspace display preference — it is purely a function of parent count. + A workspace with two real members must always get the 48/48 split, never + 96/96. `AnalyticsService` and `allocation_service`'s `_Ledger` both call this so they can never disagree on which cap applies. """ rules = settings.rules - if planning_mode == "couple" and parent_count >= 2: + if parent_count >= 2: return rules.max_savable_days_per_parent_at_age_4 return rules.max_savable_days_at_age_4 diff --git a/backend/migrations/versions/001_initial_schema.py b/backend/migrations/versions/001_initial_schema.py index f863f06..809da1d 100644 --- a/backend/migrations/versions/001_initial_schema.py +++ b/backend/migrations/versions/001_initial_schema.py @@ -1,8 +1,16 @@ -"""Initial schema — all nine tables for the full application. +"""Initial schema — full application schema, collapsed from the prior 13-migration chain. + +Migrations 001-013 are collapsed into this single one now that the chain has +never shipped to a production database (dev-only so far). This file must stay +byte-for-byte equivalent, schema-wise, to `_FULL_SCHEMA` in +`app/db/connection.py` — `tests/integration/test_schema_parity.py` asserts +exactly that by building a database from each and diffing tables, columns, +CHECKs, foreign keys and indexes. The CREATE TABLE / CREATE INDEX statements +below are copied verbatim from `_FULL_SCHEMA` so the two can never drift. Revision ID: 001 Revises: None -Create Date: 2026-05-31 +Create Date: 2026-09-18 """ from __future__ import annotations @@ -14,13 +22,12 @@ down_revision: str | None = None branch_labels: str | None = None depends_on: str | None = None -# Tables are created in FK-dependency order (workspace first). +# Tables in FK-dependency order (workspace first); indexes last. Kept in sync +# with `_FULL_SCHEMA` in `app/db/connection.py` — see module docstring. _UPGRADE_SQL = """ CREATE TABLE IF NOT EXISTS workspace ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - planning_goal TEXT NOT NULL DEFAULT 'balanced' - CHECK (planning_goal IN ('max_time','max_money','balanced')), created_at TIMESTAMP NOT NULL ); @@ -32,6 +39,8 @@ CREATE TABLE IF NOT EXISTS personal_data ( CHECK (salary_type IN ('monthly','hourly')), employment_status TEXT NOT NULL DEFAULT 'employed' CHECK (employment_status IN ('employed','jobseeking','none')), + min_vacation_days_per_year INTEGER NOT NULL DEFAULT 20 + CHECK (min_vacation_days_per_year >= 0 AND min_vacation_days_per_year <= 60), personnummer_enc TEXT, created_at TIMESTAMP NOT NULL, deleted_at TIMESTAMP, @@ -44,6 +53,7 @@ CREATE TABLE IF NOT EXISTS "user" ( email TEXT NOT NULL UNIQUE, hashed_password TEXT NOT NULL, created_at TIMESTAMP NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE ); @@ -66,20 +76,44 @@ CREATE TABLE IF NOT EXISTS child ( name TEXT NOT NULL, birth_date DATE NOT NULL, preschool_start_date DATE, + preschool_planning_days_per_year INTEGER NOT NULL DEFAULT 6 + CHECK (preschool_planning_days_per_year >= 0 AND preschool_planning_days_per_year <= 30), created_at TIMESTAMP NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS break_period ( +CREATE TABLE IF NOT EXISTS leave_wish ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + child_id TEXT, name TEXT NOT NULL, - start_date DATE NOT NULL, - end_date DATE NOT NULL, + name_message_key TEXT, + window_start_month INTEGER NOT NULL DEFAULT 1 + CHECK (window_start_month >= 1 AND window_start_month <= 12), + window_start_day INTEGER NOT NULL DEFAULT 1 + CHECK (window_start_day >= 1 AND window_start_day <= 31), + window_end_month INTEGER NOT NULL DEFAULT 1 + CHECK (window_end_month >= 1 AND window_end_month <= 12), + window_end_day INTEGER NOT NULL DEFAULT 1 + CHECK (window_end_day >= 1 AND window_end_day <= 31), target_weeks INTEGER NOT NULL, + target_days INTEGER, + recurrence TEXT NOT NULL DEFAULT 'once' + CHECK (recurrence IN ('yearly','once')), + first_year INTEGER NOT NULL DEFAULT 2026, + last_year INTEGER, + priority INTEGER NOT NULL DEFAULT 1 CHECK (priority >= 1), + parent_mode TEXT NOT NULL DEFAULT 'either' + CHECK (parent_mode IN ('either','both','specific')), + parent_personal_data_uuid TEXT, + window_mode TEXT NOT NULL DEFAULT 'fixed' + CHECK (window_mode IN ('fixed','flexible')), + wants_dubbeldag INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, + FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS parental_leave_fetch ( @@ -126,20 +160,79 @@ CREATE TABLE IF NOT EXISTS planned_leave_day ( date DATE NOT NULL, day_type TEXT NOT NULL CHECK (day_type IN ('sjukpenning','lagsta','vacation')), is_dubbeldag INTEGER NOT NULL DEFAULT 0, + wish_id TEXT, + is_pinned INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, UNIQUE(workspace_id, child_id, date, parent_personal_data_uuid), FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, - FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE + FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, + FOREIGN KEY (wish_id) REFERENCES leave_wish(id) ON DELETE SET NULL ); + +CREATE TABLE IF NOT EXISTS day_override ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + date DATE NOT NULL, + is_working_day INTEGER NOT NULL, + note TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE(workspace_id, date), + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS tip_dismissal ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + tip_id TEXT NOT NULL, + data_hash TEXT NOT NULL, + dismissed_at TIMESTAMP NOT NULL, + UNIQUE(workspace_id, tip_id), + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_pld_parent_child_type_date + ON planned_leave_day (parent_personal_data_uuid, child_id, day_type, date); +CREATE INDEX IF NOT EXISTS idx_pld_child_dubbeldag_date + ON planned_leave_day (child_id, is_dubbeldag, date); +CREATE INDEX IF NOT EXISTS idx_pld_wish + ON planned_leave_day (wish_id); +CREATE INDEX IF NOT EXISTS idx_plf_parent_child_fetched + ON parental_leave_fetch (personal_data_uuid, child_id, fetched_at); +CREATE INDEX IF NOT EXISTS idx_plf_workspace + ON parental_leave_fetch (workspace_id); +CREATE INDEX IF NOT EXISTS idx_plf_child + ON parental_leave_fetch (child_id); +CREATE INDEX IF NOT EXISTS idx_leave_wish_workspace + ON leave_wish (workspace_id); +CREATE INDEX IF NOT EXISTS idx_leave_wish_child + ON leave_wish (child_id); +CREATE INDEX IF NOT EXISTS idx_leave_wish_parent + ON leave_wish (parent_personal_data_uuid); +CREATE INDEX IF NOT EXISTS idx_child_workspace_birth + ON child (workspace_id, birth_date); +CREATE INDEX IF NOT EXISTS idx_personal_data_workspace_deleted + ON personal_data (workspace_id, deleted_at); +CREATE INDEX IF NOT EXISTS idx_invitation_workspace_status_created + ON workspace_invitation (workspace_id, status, created_at); +CREATE INDEX IF NOT EXISTS idx_invitation_invited_by + ON workspace_invitation (invited_by_user_id); +CREATE INDEX IF NOT EXISTS idx_vacation_days_workspace + ON vacation_days (workspace_id); """ +# Reverse FK-dependency order: drop dependents before their targets. Dropping +# a table also drops its own indexes, so no separate DROP INDEX statements +# are needed. _DOWNGRADE_SQL = """ +DROP TABLE IF EXISTS tip_dismissal; +DROP TABLE IF EXISTS day_override; DROP TABLE IF EXISTS planned_leave_day; DROP TABLE IF EXISTS vacation_days; DROP TABLE IF EXISTS parental_leave_fetch; -DROP TABLE IF EXISTS break_period; +DROP TABLE IF EXISTS leave_wish; DROP TABLE IF EXISTS child; DROP TABLE IF EXISTS workspace_invitation; DROP TABLE IF EXISTS "user"; diff --git a/backend/migrations/versions/002_add_deduced_source.py b/backend/migrations/versions/002_add_deduced_source.py new file mode 100644 index 0000000..2ab197f --- /dev/null +++ b/backend/migrations/versions/002_add_deduced_source.py @@ -0,0 +1,134 @@ +"""Add 'deduced' to parental_leave_fetch.source's CHECK constraint. + +`source` gains a third value alongside 'scraped'/'manual': 'deduced', for a +snapshot the app derived on the user's behalf (e.g. defaulting an unentered +total) rather than one FK reported or the user typed in directly. SQLite has +no `ALTER TABLE ... DROP/ADD CONSTRAINT`, so the table is recreated with the +new CHECK and its rows copied across — the standard SQLite migration dance +(see e.g. https://sqlite.org/lang_altertable.html#otheralter). Recreating the +table also drops its indexes, so they're recreated afterwards. + +Must stay byte-for-byte equivalent, CHECK-constraint-wise, to +`parental_leave_fetch` in `app/db/connection.py`'s `_FULL_SCHEMA` — +`tests/integration/test_schema_parity.py` asserts exactly that. + +Revision ID: 002 +Revises: 001 +Create Date: 2026-09-19 +""" + +from __future__ import annotations + +from alembic import op + +revision: str = "002" +down_revision: str | None = "001" +branch_labels: str | None = None +depends_on: str | None = None + +_UPGRADE_SQL = """ +CREATE TABLE parental_leave_fetch_new ( + id TEXT PRIMARY KEY, + personal_data_uuid TEXT NOT NULL, + workspace_id TEXT NOT NULL, + child_id TEXT NOT NULL, + source TEXT NOT NULL CHECK (source IN ('scraped','manual','deduced')), + fetched_at TIMESTAMP NOT NULL, + total_days INTEGER NOT NULL, + days_used INTEGER NOT NULL, + days_left INTEGER NOT NULL, + reserved_days_total INTEGER NOT NULL DEFAULT 90, + reserved_days_left INTEGER NOT NULL, + transferable_days_left INTEGER NOT NULL, + dubbeldagar_left INTEGER NOT NULL, + tier_data TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE +); + +INSERT INTO parental_leave_fetch_new ( + id, personal_data_uuid, workspace_id, child_id, source, fetched_at, + total_days, days_used, days_left, reserved_days_total, reserved_days_left, + transferable_days_left, dubbeldagar_left, tier_data, created_at, updated_at +) +SELECT + id, personal_data_uuid, workspace_id, child_id, source, fetched_at, + total_days, days_used, days_left, reserved_days_total, reserved_days_left, + transferable_days_left, dubbeldagar_left, tier_data, created_at, updated_at +FROM parental_leave_fetch; + +DROP TABLE parental_leave_fetch; + +ALTER TABLE parental_leave_fetch_new RENAME TO parental_leave_fetch; + +CREATE INDEX IF NOT EXISTS idx_plf_parent_child_fetched + ON parental_leave_fetch (personal_data_uuid, child_id, fetched_at); +CREATE INDEX IF NOT EXISTS idx_plf_workspace + ON parental_leave_fetch (workspace_id); +CREATE INDEX IF NOT EXISTS idx_plf_child + ON parental_leave_fetch (child_id); +""" + +_DOWNGRADE_SQL = """ +CREATE TABLE parental_leave_fetch_old ( + id TEXT PRIMARY KEY, + personal_data_uuid TEXT NOT NULL, + workspace_id TEXT NOT NULL, + child_id TEXT NOT NULL, + source TEXT NOT NULL CHECK (source IN ('scraped','manual')), + fetched_at TIMESTAMP NOT NULL, + total_days INTEGER NOT NULL, + days_used INTEGER NOT NULL, + days_left INTEGER NOT NULL, + reserved_days_total INTEGER NOT NULL DEFAULT 90, + reserved_days_left INTEGER NOT NULL, + transferable_days_left INTEGER NOT NULL, + dubbeldagar_left INTEGER NOT NULL, + tier_data TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE +); + +INSERT INTO parental_leave_fetch_old ( + id, personal_data_uuid, workspace_id, child_id, source, fetched_at, + total_days, days_used, days_left, reserved_days_total, reserved_days_left, + transferable_days_left, dubbeldagar_left, tier_data, created_at, updated_at +) +SELECT + id, personal_data_uuid, workspace_id, child_id, source, fetched_at, + total_days, days_used, days_left, reserved_days_total, reserved_days_left, + transferable_days_left, dubbeldagar_left, tier_data, created_at, updated_at +FROM parental_leave_fetch +WHERE source != 'deduced'; + +DROP TABLE parental_leave_fetch; + +ALTER TABLE parental_leave_fetch_old RENAME TO parental_leave_fetch; + +CREATE INDEX IF NOT EXISTS idx_plf_parent_child_fetched + ON parental_leave_fetch (personal_data_uuid, child_id, fetched_at); +CREATE INDEX IF NOT EXISTS idx_plf_workspace + ON parental_leave_fetch (workspace_id); +CREATE INDEX IF NOT EXISTS idx_plf_child + ON parental_leave_fetch (child_id); +""" + + +def upgrade() -> None: + from sqlalchemy import text + + for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: + op.execute(text(stmt)) + + +def downgrade() -> None: + from sqlalchemy import text + + for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: + op.execute(text(stmt)) diff --git a/backend/migrations/versions/002_planning_mode_and_preschool_days.py b/backend/migrations/versions/002_planning_mode_and_preschool_days.py deleted file mode 100644 index acdb256..0000000 --- a/backend/migrations/versions/002_planning_mode_and_preschool_days.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Add workspace.planning_mode and child.preschool_planning_days_per_year. - -Revision ID: 002 -Revises: 001 -Create Date: 2026-09-12 -""" - -from __future__ import annotations - -from alembic import op - -revision: str = "002" -down_revision: str | None = "001" -branch_labels: str | None = None -depends_on: str | None = None - -_UPGRADE_SQL = """ -ALTER TABLE workspace ADD COLUMN planning_mode TEXT NOT NULL DEFAULT 'solo' - CHECK (planning_mode IN ('solo','couple')); - -ALTER TABLE child ADD COLUMN preschool_planning_days_per_year INTEGER NOT NULL DEFAULT 6 - CHECK (preschool_planning_days_per_year >= 0 AND preschool_planning_days_per_year <= 30); -""" - -_DOWNGRADE_SQL = """ -ALTER TABLE child DROP COLUMN preschool_planning_days_per_year; -ALTER TABLE workspace DROP COLUMN planning_mode; -""" - - -def upgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) - - -def downgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) diff --git a/backend/migrations/versions/003_rename_break_period_to_leave_wish.py b/backend/migrations/versions/003_rename_break_period_to_leave_wish.py deleted file mode 100644 index f0932e6..0000000 --- a/backend/migrations/versions/003_rename_break_period_to_leave_wish.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Rename break_period -> leave_wish and add recurrence/priority/parent fields. - -`break_period` modeled "N weeks home together in this window" — single-year, -no recurrence, no priority, no notion of which parent. The planning engine -(PLANNING_ENGINE_SPEC.md §2.1) needs all three, so the table is renamed and -extended rather than adding a parallel concept. Safe to do now: the table has -zero rows in production. - -The recurring window is stored as month/day integers (not dates) so it can -repeat yearly without a custom SQLite type; old `start_date`/`end_date` rows -are backfilled into `window_*` columns and the date columns are then dropped. - -Revision ID: 003 -Revises: 002 -Create Date: 2026-09-12 -""" - -from __future__ import annotations - -from alembic import op - -revision: str = "003" -down_revision: str | None = "002" -branch_labels: str | None = None -depends_on: str | None = None - -_UPGRADE_SQL = """ -ALTER TABLE break_period RENAME TO leave_wish; - -ALTER TABLE leave_wish ADD COLUMN child_id TEXT - REFERENCES child(id) ON DELETE CASCADE; - -ALTER TABLE leave_wish ADD COLUMN window_start_month INTEGER NOT NULL DEFAULT 1 - CHECK (window_start_month >= 1 AND window_start_month <= 12); - -ALTER TABLE leave_wish ADD COLUMN window_start_day INTEGER NOT NULL DEFAULT 1 - CHECK (window_start_day >= 1 AND window_start_day <= 31); - -ALTER TABLE leave_wish ADD COLUMN window_end_month INTEGER NOT NULL DEFAULT 1 - CHECK (window_end_month >= 1 AND window_end_month <= 12); - -ALTER TABLE leave_wish ADD COLUMN window_end_day INTEGER NOT NULL DEFAULT 1 - CHECK (window_end_day >= 1 AND window_end_day <= 31); - -ALTER TABLE leave_wish ADD COLUMN recurrence TEXT NOT NULL DEFAULT 'once' - CHECK (recurrence IN ('yearly','once')); - -ALTER TABLE leave_wish ADD COLUMN first_year INTEGER NOT NULL DEFAULT 2026; - -ALTER TABLE leave_wish ADD COLUMN last_year INTEGER; - -ALTER TABLE leave_wish ADD COLUMN priority INTEGER NOT NULL DEFAULT 1 - CHECK (priority >= 1); - -ALTER TABLE leave_wish ADD COLUMN parent_mode TEXT NOT NULL DEFAULT 'either' - CHECK (parent_mode IN ('either','both','specific')); - -ALTER TABLE leave_wish ADD COLUMN parent_personal_data_uuid TEXT - REFERENCES personal_data(uuid) ON DELETE CASCADE; - -ALTER TABLE leave_wish ADD COLUMN goal_override TEXT - CHECK (goal_override IS NULL OR goal_override IN ('max_time','max_money','balanced')); - -UPDATE leave_wish SET - recurrence = 'once', - priority = 1, - parent_mode = 'either', - first_year = CAST(strftime('%Y', start_date) AS INTEGER), - last_year = CAST(strftime('%Y', start_date) AS INTEGER), - window_start_month = CAST(strftime('%m', start_date) AS INTEGER), - window_start_day = CAST(strftime('%d', start_date) AS INTEGER), - window_end_month = CAST(strftime('%m', end_date) AS INTEGER), - window_end_day = CAST(strftime('%d', end_date) AS INTEGER); - -ALTER TABLE leave_wish DROP COLUMN start_date; - -ALTER TABLE leave_wish DROP COLUMN end_date; -""" - -_DOWNGRADE_SQL = """ -ALTER TABLE leave_wish ADD COLUMN start_date DATE; - -ALTER TABLE leave_wish ADD COLUMN end_date DATE; - -UPDATE leave_wish SET - start_date = printf('%04d-%02d-%02d', first_year, window_start_month, window_start_day), - end_date = printf( - '%04d-%02d-%02d', - CASE WHEN (window_end_month, window_end_day) < (window_start_month, window_start_day) - THEN first_year + 1 ELSE first_year END, - window_end_month, - window_end_day - ); - -ALTER TABLE leave_wish DROP COLUMN goal_override; - -ALTER TABLE leave_wish DROP COLUMN parent_personal_data_uuid; - -ALTER TABLE leave_wish DROP COLUMN parent_mode; - -ALTER TABLE leave_wish DROP COLUMN priority; - -ALTER TABLE leave_wish DROP COLUMN last_year; - -ALTER TABLE leave_wish DROP COLUMN first_year; - -ALTER TABLE leave_wish DROP COLUMN recurrence; - -ALTER TABLE leave_wish DROP COLUMN window_end_day; - -ALTER TABLE leave_wish DROP COLUMN window_end_month; - -ALTER TABLE leave_wish DROP COLUMN window_start_day; - -ALTER TABLE leave_wish DROP COLUMN window_start_month; - -ALTER TABLE leave_wish DROP COLUMN child_id; - -ALTER TABLE leave_wish RENAME TO break_period; -""" - - -def upgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) - - -def downgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) diff --git a/backend/migrations/versions/004_add_day_override.py b/backend/migrations/versions/004_add_day_override.py deleted file mode 100644 index f46e6eb..0000000 --- a/backend/migrations/versions/004_add_day_override.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Add day_override table. - -Lets a workspace correct the calendar cache (`app/data/swedish_calendar.json`) -in either direction on a per-date basis: `is_working_day=false` means the -workspace is off that day even though the calendar calls it a working day -(e.g. a klämdag their employer gives off); `is_working_day=true` means the -workspace must work despite the calendar (e.g. julafton is a normal workday -there). See PLANNING_ENGINE task notes / app.services.allocation_service. - -Revision ID: 004 -Revises: 003 -Create Date: 2026-09-13 -""" - -from __future__ import annotations - -from alembic import op - -revision: str = "004" -down_revision: str | None = "003" -branch_labels: str | None = None -depends_on: str | None = None - -_UPGRADE_SQL = """ -CREATE TABLE IF NOT EXISTS day_override ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - date DATE NOT NULL, - is_working_day INTEGER NOT NULL, - note TEXT, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - UNIQUE(workspace_id, date), - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE -); -""" - -_DOWNGRADE_SQL = """ -DROP TABLE IF EXISTS day_override; -""" - - -def upgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) - - -def downgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) diff --git a/backend/migrations/versions/005_add_personal_data_min_vacation_days.py b/backend/migrations/versions/005_add_personal_data_min_vacation_days.py deleted file mode 100644 index 8d537d3..0000000 --- a/backend/migrations/versions/005_add_personal_data_min_vacation_days.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Add personal_data.min_vacation_days_per_year. - -Semesterlagen requires a minimum number of vacation days to be *taken* each -year (statutory 20); only days above that minimum may be saved, for up to 5 -years. Employers may set a higher floor, and entitlements differ per parent, -so this is modelled per PersonalData row rather than as a single global -constant. See PLANNING_ENGINE_SPEC.md §9.2. - -Revision ID: 005 -Revises: 004 -Create Date: 2026-09-13 -""" - -from __future__ import annotations - -from alembic import op - -revision: str = "005" -down_revision: str | None = "004" -branch_labels: str | None = None -depends_on: str | None = None - -_UPGRADE_SQL = """ -ALTER TABLE personal_data ADD COLUMN min_vacation_days_per_year INTEGER NOT NULL DEFAULT 20 - CHECK (min_vacation_days_per_year >= 0 AND min_vacation_days_per_year <= 60); -""" - -_DOWNGRADE_SQL = """ -ALTER TABLE personal_data DROP COLUMN min_vacation_days_per_year; -""" - - -def upgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) - - -def downgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) diff --git a/backend/migrations/versions/006_add_leave_wish_window_mode.py b/backend/migrations/versions/006_add_leave_wish_window_mode.py deleted file mode 100644 index 8ebf794..0000000 --- a/backend/migrations/versions/006_add_leave_wish_window_mode.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Add leave_wish.window_mode ('fixed' | 'flexible'). - -PLANNING_ENGINE_SPEC.md §10.1 — "när det passar bäst" flexible wishes. For a -'flexible' wish the EXISTING window_start_*/window_end_* columns are -reinterpreted as the bounding range the allocator searches within, rather -than the exact placement; no new columns are needed for the bound itself. -Defaults to 'fixed' so every existing row (and every existing construction -site) keeps its current, unchanged meaning. - -Revision ID: 006 -Revises: 005 -Create Date: 2026-09-13 -""" - -from __future__ import annotations - -from alembic import op - -revision: str = "006" -down_revision: str | None = "005" -branch_labels: str | None = None -depends_on: str | None = None - -_UPGRADE_SQL = """ -ALTER TABLE leave_wish ADD COLUMN window_mode TEXT NOT NULL DEFAULT 'fixed' - CHECK (window_mode IN ('fixed','flexible')); -""" - -_DOWNGRADE_SQL = """ -ALTER TABLE leave_wish DROP COLUMN window_mode; -""" - - -def upgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) - - -def downgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) diff --git a/backend/migrations/versions/007_add_leave_wish_name_message_key.py b/backend/migrations/versions/007_add_leave_wish_name_message_key.py deleted file mode 100644 index 55fd4e7..0000000 --- a/backend/migrations/versions/007_add_leave_wish_name_message_key.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Add leave_wish.name_message_key (nullable i18n key for preset names). - -Bug: `WishEditor.tsx` created preset wishes (Sommar/Jullov/Höstlov/...) with -`name` set to the already-translated string (e.g. "Jul"), persisted as-is. -Switching the UI language afterwards could never fix a wish's name, because -the Swedish (or English) text was the data, not a rendering choice. - -Fix: `name_message_key` stores the i18n key that produced `name` at creation -time (e.g. "wishEditor.seasonJul"), alongside the translated `name` so -anything reading `name` directly (backend, exports, ...) still gets a sane -value. The frontend re-translates via the key when present and falls back to -the literal `name` otherwise. Nullable and no default: every existing row -(and any row created by a free-typed custom name) has no key and must keep -showing its stored `name` unchanged — see `LeaveWishService.update`, which -also force-clears the key the moment a user edits `name`. - -Revision ID: 007 -Revises: 006 -Create Date: 2026-09-14 -""" - -from __future__ import annotations - -from alembic import op - -revision: str = "007" -down_revision: str | None = "006" -branch_labels: str | None = None -depends_on: str | None = None - -_UPGRADE_SQL = """ -ALTER TABLE leave_wish ADD COLUMN name_message_key TEXT; -""" - -_DOWNGRADE_SQL = """ -ALTER TABLE leave_wish DROP COLUMN name_message_key; -""" - - -def upgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) - - -def downgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) diff --git a/backend/migrations/versions/008_add_wish_link_and_target_days.py b/backend/migrations/versions/008_add_wish_link_and_target_days.py deleted file mode 100644 index ce6585f..0000000 --- a/backend/migrations/versions/008_add_wish_link_and_target_days.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Link planned_leave_day back to the wish that produced it; add pin + day-granular target. - -Persisted leave days currently have no link back to the `leave_wish` that -produced them: the allocator's `ProposedDay` carries `wish_id`, but it was -deliberately stripped at persist time. That makes it impossible for the UI to -click a multi-day band on a calendar and edit it as one period — the entire -point of the UX rework this migration is the keystone for. - -Three additive, independently-nullable/defaulted changes (no backfill -possible or needed — every existing row predates this link): - -1. `planned_leave_day.wish_id` — which wish produced this day. NULL = ad-hoc - / legacy / unattributable (e.g. a day added by hand, or persisted before - this column existed). `ON DELETE SET NULL` so deleting a wish later never - cascades into deleting the days it already funded — it only orphans them - back to "ad-hoc", matching the nullable semantics. -2. `planned_leave_day.is_pinned` — marks a day the user hand-edited so a - later allocator pass knows not to retype it. This migration only adds the - column and carries it through the read/write path; the allocator itself - does not yet honor it (a follow-up task wires that up). -3. `leave_wish.target_days` — a calendar-drawn period is not a whole number - of weeks. NULL (the default for every existing row) falls back to - `target_weeks * 7` via `LeaveWish.target_span_days()`. - -Revision ID: 008 -Revises: 007 -Create Date: 2026-09-15 -""" - -from __future__ import annotations - -from alembic import op - -revision: str = "008" -down_revision: str | None = "007" -branch_labels: str | None = None -depends_on: str | None = None - -_UPGRADE_SQL = """ -ALTER TABLE planned_leave_day ADD COLUMN wish_id TEXT - REFERENCES leave_wish(id) ON DELETE SET NULL; - -ALTER TABLE planned_leave_day ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0; - -ALTER TABLE leave_wish ADD COLUMN target_days INTEGER; -""" - -_DOWNGRADE_SQL = """ -ALTER TABLE leave_wish DROP COLUMN target_days; - -ALTER TABLE planned_leave_day DROP COLUMN is_pinned; - -ALTER TABLE planned_leave_day DROP COLUMN wish_id; -""" - - -def upgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) - - -def downgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) diff --git a/backend/migrations/versions/009_drop_planning_goal.py b/backend/migrations/versions/009_drop_planning_goal.py deleted file mode 100644 index 83e09c0..0000000 --- a/backend/migrations/versions/009_drop_planning_goal.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Drop workspace.planning_goal and leave_wish.goal_override. - -Product decision: there is only one sensible allocation objective — maximise -the value extracted from parental-leave days plus vacation days. Exposing -`max_time` / `max_money` / `balanced` as a user-facing setting made the -planner's behaviour unpredictable without making it more useful. The engine -now always applies what used to be the `max_money` policy; the `goal` -concept (and the ability to override it per-wish) is removed entirely. - -Revision ID: 009 -Revises: 008 -Create Date: 2026-09-15 -""" - -from __future__ import annotations - -from alembic import op - -revision: str = "009" -down_revision: str | None = "008" -branch_labels: str | None = None -depends_on: str | None = None - -_UPGRADE_SQL = """ -ALTER TABLE workspace DROP COLUMN planning_goal; - -ALTER TABLE leave_wish DROP COLUMN goal_override; -""" - -_DOWNGRADE_SQL = """ -ALTER TABLE workspace ADD COLUMN planning_goal TEXT NOT NULL DEFAULT 'balanced' - CHECK (planning_goal IN ('max_time','max_money','balanced')); - -ALTER TABLE leave_wish ADD COLUMN goal_override TEXT - CHECK (goal_override IS NULL OR goal_override IN ('max_time','max_money','balanced')); -""" - - -def upgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) - - -def downgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) diff --git a/backend/migrations/versions/010_add_tip_dismissal.py b/backend/migrations/versions/010_add_tip_dismissal.py deleted file mode 100644 index e5a4575..0000000 --- a/backend/migrations/versions/010_add_tip_dismissal.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Add tip_dismissal table for persisting tip dismissals. - -`useOptimizationTips` only kept dismissed tip ids in a local `Set` — they came -back on every reload. This adds a workspace-scoped dismissal record keyed by -`(workspace_id, tip_id)` so a dismissed tip stays dismissed across reloads, -until the underlying situation changes materially. - -`data_hash` is a fingerprint of the tip's `data` payload at dismiss time -(see `app.utils.tip_predicates.hash_tip_data`). `TipsService` only honors a -dismissal while the rule's current output still hashes the same; once the -numbers move (e.g. more days now projected to expire at age 4), the -dismissal is stale and the tip resurfaces automatically. A dismissal that -never expired could permanently silence a safety-relevant warning such as -`preserve_for_age_4` — that would be worse than not persisting dismissals at -all, so expiry-on-change is not optional here. - -Revision ID: 010 -Revises: 009 -Create Date: 2026-09-17 -""" - -from __future__ import annotations - -from alembic import op - -revision: str = "010" -down_revision: str | None = "009" -branch_labels: str | None = None -depends_on: str | None = None - -_UPGRADE_SQL = """ -CREATE TABLE tip_dismissal ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - tip_id TEXT NOT NULL, - data_hash TEXT NOT NULL, - dismissed_at TIMESTAMP NOT NULL, - UNIQUE(workspace_id, tip_id), - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE -); -""" - -_DOWNGRADE_SQL = """ -DROP TABLE tip_dismissal; -""" - - -def upgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) - - -def downgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) diff --git a/backend/migrations/versions/011_add_indexes.py b/backend/migrations/versions/011_add_indexes.py deleted file mode 100644 index 67db585..0000000 --- a/backend/migrations/versions/011_add_indexes.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Add indexes on hot filter/FK-cascade columns, and user.is_active. - -Backend-perf audit (`scratchpad/audit/backend-perf.md` §4, M1): every -migration to date created zero indexes; `EXPLAIN QUERY PLAN` against the full -schema shows several hot paths doing a full table SCAN once the DB carries -more than a handful of rows per table (multi-workspace deployment, years of -FK-fetch history) — `planned_leave_day.count_by_parent_type` (2x per -parent+child in analytics and plan/apply), `count_dubbeldagar`, -`parental_leave_fetch.get_latest_by_parent`, and every FK-cascade column that -`ON DELETE CASCADE` walks on a GDPR delete. These 14 indexes cover exactly -those columns; see the audit for the full per-index rationale. Every -`CREATE INDEX` below is mirrored verbatim (same columns, same order, no -partial indexes) in `_FULL_SCHEMA` (`app/db/connection.py`) so -`tests/integration/test_schema_parity.py` — which compares index sets between -the Alembic chain and that hand-maintained DDL — stays green. - -Also adds `user.is_active` (default 1): `AuthService.login` and -`get_user_from_token` already check `User.is_active` (account -deactivation), but the column never existed — every account was -unconditionally treated as active. This migration adds it and -`SQLiteUserRepository` now reads/writes it. - -Revision ID: 011 -Revises: 010 -Create Date: 2026-09-18 -""" - -from __future__ import annotations - -from alembic import op - -revision: str = "011" -down_revision: str | None = "010" -branch_labels: str | None = None -depends_on: str | None = None - -_UPGRADE_SQL = """ -ALTER TABLE "user" ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1; -CREATE INDEX idx_pld_parent_child_type_date - ON planned_leave_day (parent_personal_data_uuid, child_id, day_type, date); -CREATE INDEX idx_pld_child_dubbeldag_date - ON planned_leave_day (child_id, is_dubbeldag, date); -CREATE INDEX idx_pld_wish - ON planned_leave_day (wish_id); -CREATE INDEX idx_plf_parent_child_fetched - ON parental_leave_fetch (personal_data_uuid, child_id, fetched_at); -CREATE INDEX idx_plf_workspace - ON parental_leave_fetch (workspace_id); -CREATE INDEX idx_plf_child - ON parental_leave_fetch (child_id); -CREATE INDEX idx_leave_wish_workspace - ON leave_wish (workspace_id); -CREATE INDEX idx_leave_wish_child - ON leave_wish (child_id); -CREATE INDEX idx_leave_wish_parent - ON leave_wish (parent_personal_data_uuid); -CREATE INDEX idx_child_workspace_birth - ON child (workspace_id, birth_date); -CREATE INDEX idx_personal_data_workspace_deleted - ON personal_data (workspace_id, deleted_at); -CREATE INDEX idx_invitation_workspace_status_created - ON workspace_invitation (workspace_id, status, created_at); -CREATE INDEX idx_invitation_invited_by - ON workspace_invitation (invited_by_user_id); -CREATE INDEX idx_vacation_days_workspace - ON vacation_days (workspace_id); -""" - -_DOWNGRADE_SQL = """ -DROP INDEX idx_vacation_days_workspace; -DROP INDEX idx_invitation_invited_by; -DROP INDEX idx_invitation_workspace_status_created; -DROP INDEX idx_personal_data_workspace_deleted; -DROP INDEX idx_child_workspace_birth; -DROP INDEX idx_leave_wish_parent; -DROP INDEX idx_leave_wish_child; -DROP INDEX idx_leave_wish_workspace; -DROP INDEX idx_plf_child; -DROP INDEX idx_plf_workspace; -DROP INDEX idx_plf_parent_child_fetched; -DROP INDEX idx_pld_wish; -DROP INDEX idx_pld_child_dubbeldag_date; -DROP INDEX idx_pld_parent_child_type_date; -ALTER TABLE "user" DROP COLUMN is_active; -""" - - -def upgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) - - -def downgrade() -> None: - from sqlalchemy import text - - for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: - op.execute(text(stmt)) diff --git a/backend/scripts/demo_plan.py b/backend/scripts/demo_plan.py index 43d6547..fea24d2 100644 --- a/backend/scripts/demo_plan.py +++ b/backend/scripts/demo_plan.py @@ -124,8 +124,7 @@ def _build_input() -> tuple[AllocationInput, dict]: alloc_input = AllocationInput( today=TODAY, horizon_end=horizon_end, - planning_mode="couple", - age4_savable_cap=resolve_age4_savable_cap("couple", parent_count=2), + age4_savable_cap=resolve_age4_savable_cap(parent_count=2), children=[child], parents=[parent_1, parent_2], pools=pools, diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py index 6d44967..6d029bb 100644 --- a/backend/scripts/seed_demo.py +++ b/backend/scripts/seed_demo.py @@ -214,7 +214,6 @@ async def _seed(conn: aiosqlite.Connection, *, reset: bool) -> None: email=DEMO_EMAIL, password=DEMO_PASSWORD, display_name=DEMO_NAME ) workspace_id = alex.workspace_id - await services.workspace.update_workspace(workspace_id, planning_mode="couple") # -- 2. Partner (no login — shared/partner planning needs a second parent # on the workspace, but nothing requires them to have registered) ------- @@ -261,15 +260,10 @@ async def _seed(conn: aiosqlite.Connection, *, reset: bool) -> None: workspace_id=workspace_id, child_id=child.id, total_days=240, - days_used=240, - reserved_days_total=90, + sjukpenning_left=0, + lagsta_niva_left=0, reserved_days_left=0, - transferable_days_left=0, dubbeldagar_left=55, - sjukpenning_total=195, - sjukpenning_used=195, - lagsta_niva_total=45, - lagsta_niva_used=45, ) # -- 6. Day overrides ----------------------------------------------- @@ -398,7 +392,7 @@ def _print_summary( print(f" Password: {DEMO_PASSWORD}") print() print("Created:") - print(f" Workspace id: {workspace_id} (planning_mode=couple)") + print(f" Workspace id: {workspace_id} (couple: 2 parents)") print(f" Parent 1: {DEMO_NAME} ({alex.uuid}) — has a login, near-drained FP pool") print(f" Parent 2: {PARTNER_NAME} ({sam.uuid}) — no login, large surplus pool") print(f" Child: {CHILD_NAME} ({child_id}), born {birth_date.isoformat()}") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 63318f9..6be0195 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -353,6 +353,7 @@ async def planning_service( parental_leave_repo: SQLiteParentalLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, planning_engine_service: PlanningEngineService, + leave_wish_repo: SQLiteLeaveWishRepository, ) -> PlanningService: return PlanningService( planned_leave_repo, @@ -360,6 +361,7 @@ async def planning_service( parental_leave_repo, vacation_repo, planning_engine_service, + leave_wish_repo=leave_wish_repo, ) @@ -385,6 +387,7 @@ async def planning_service_with_uow( parental_leave_repo: SQLiteParentalLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, planning_engine_service: PlanningEngineService, + leave_wish_repo: SQLiteLeaveWishRepository, sqlite_uow: SQLiteUnitOfWork, ) -> PlanningService: return PlanningService( @@ -393,6 +396,7 @@ async def planning_service_with_uow( parental_leave_repo, vacation_repo, planning_engine_service, + leave_wish_repo=leave_wish_repo, uow=sqlite_uow, ) @@ -417,7 +421,6 @@ async def tips_service( personal_data_repo: SQLitePersonalDataRepository, vacation_repo: SQLiteVacationDaysRepository, leave_wish_repo: SQLiteLeaveWishRepository, - workspace_repo: SQLiteWorkspaceRepository, tip_dismissal_repo: SQLiteTipDismissalRepository, ) -> TipsService: return TipsService( @@ -427,6 +430,5 @@ async def tips_service( personal_data_repo, vacation_repo, leave_wish_repo, - workspace_repo, tip_dismissal_repo, ) diff --git a/backend/tests/fixtures/fk_overview_synthetic.html b/backend/tests/fixtures/fk_overview_synthetic.html index 5e41903..a3870fd 100644 --- a/backend/tests/fixtures/fk_overview_synthetic.html +++ b/backend/tests/fixtures/fk_overview_synthetic.html @@ -4,11 +4,16 @@ This is NOT a real Försäkringskassan page dump — it is a hand-built, minimal HTML fragment that reproduces only the DOM structure and CSS - selectors that `app/scrapers/fk_scraper.py::_extract_parental_leave` - reads (see `_SEL` in that module). All names, day counts, and amounts - below are fictitious (see F1 of the scraper/config/ops security audit — - the previous fixture was a real capture containing a developer's - family's actual FK balances and was removed from the repo). + selectors that `app/scrapers/fk_scraper.py::_extract_parental_leave` and + `_extract_family_summary` read (see `_SEL` in that module). All names, day + counts, and amounts below are fictitious (see F1 of the scraper/config/ops + security audit — the previous fixture was a real capture containing a + developer's family's actual FK balances and was removed from the repo). + + The `forDigOchAnnanForalder` ("för dig och annan förälder") panel's + `visa-dagar` block below is an ASSUMED structure mirroring `forDig`'s — + the real FK markup for this panel has not yet been captured; see the + comment on `_SEL.FAMILY*` in `fk_scraper.py`. --> @@ -56,6 +61,33 @@

Totalt för Testbarn

Exempel på belopp: 1 234 kronor för 5 dagar.

+
+
+
+
+ 250 + av 480 dagar kvar efter ansökan +
+
+
+ +
+

Sjukpenningnivå (190 dagar)

+
+
+
+ +
+

Lägstanivå (60 dagar)

+
+
+
+
+
+
+
+
+
diff --git a/backend/tests/integration/test_user_journey.py b/backend/tests/integration/test_user_journey.py index 37fe2fd..87b3c80 100644 --- a/backend/tests/integration/test_user_journey.py +++ b/backend/tests/integration/test_user_journey.py @@ -135,7 +135,11 @@ async def _set_full_parental_leave( """Record a manual FK snapshot via the real `/api/parental-leave/manual` endpoint (the always-available alternative to the scraper), defaulting - to a full, untouched 390+90 pool. + to a full, untouched 390+90 pool. `reserved_days_left` is left for + `build_snapshot` to derive (transferable-spent-first) rather than + hardcoded, since a hardcoded 90 would be inconsistent with a + (sjukpenning_used, lagsta_niva_used) combination that drains past the + 150 transferable days into the reserved ones. """ resp = await client.post( "/api/parental-leave/manual", @@ -145,15 +149,9 @@ async def _set_full_parental_leave( "workspace_id": str(workspace_id), "child_id": str(child_id), "total_days": 480, - "days_used": sjukpenning_used + lagsta_niva_used, - "reserved_days_total": 90, - "reserved_days_left": 90, - "transferable_days_left": 150, + "sjukpenning_left": 390 - sjukpenning_used, + "lagsta_niva_left": 90 - lagsta_niva_used, "dubbeldagar_left": 60, - "sjukpenning_total": 390, - "sjukpenning_used": sjukpenning_used, - "lagsta_niva_total": 90, - "lagsta_niva_used": lagsta_niva_used, }, ) assert resp.status_code == 201, resp.text @@ -227,6 +225,15 @@ def _to_apply_item(d: dict[str, Any]) -> dict[str, Any]: "child_id": d["child_id"], "day_type": d["day_type"], "is_dubbeldag": d["is_dubbeldag"], + # §fix (parent-reassignment moves dates, decision B) — a faithful + # caller round-trips `wish_id` from the propose response exactly + # like every other field here; omitting it used to be harmless + # (the old reuse match was parent-based) but `_persisted_by_wish` + # now needs it to recognise a flexible wish's own prior placement + # on the next propose (see allocation_service.py's `_Ledger + # .__init__`). Real callers already send it -- `ProposedDayResponse` + # (and hence this `d`) always carries a non-null `wish_id`. + "wish_id": d["wish_id"], } @@ -725,17 +732,21 @@ class TestAge4ProjectionArithmeticIsSelfConsistent: (`allocation_service.py:1446-1475`). No wish is created in this test, so nothing gets proposed and `planned_fp_before_b4` is 0. With no `ParentalLeaveFetch` snapshot for this brand-new - registration, that pool falls back to `rules.sjukpenning_days // 2 + - rules.lagsta_niva_days // 2`, which is `rules.days_per_parent` - exactly (the "Bug A" default the two must agree on -- see + registration, that pool falls back to `default_tier_pool_share` + (`app.utils.tip_predicates`), which gives a SOLE parent (this + workspace has exactly one member) the full + `rules.sjukpenning_days`/`rules.lagsta_niva_days` pool rather than + half of it -- summing to `rules.total_days_per_child` exactly (the + "Bug A" default the allocation engine and `PlanningService. + _tier_pool_left` must agree on -- see `test_planning_service_apply.py`'s `test_no_snapshot_default_balance_matches_allocation_engine`) -- - making `projected_left_at_b4 == rules.days_per_parent`. The + making `projected_left_at_b4 == rules.total_days_per_child`. The workspace is a fresh single-member 'solo' workspace (never set to 'couple' here), so the per-CHILD cap (`rules.max_savable_days_at_age_4`) applies via `resolve_age4_savable_cap`, not the per-parent one -- making - `expiring_at_b4 == max(rules.days_per_parent - + `expiring_at_b4 == max(rules.total_days_per_child - rules.max_savable_days_at_age_4, 0)`. """ headers, ws_id, pd_uuid = await _register(client, "age4-projection@example.com", "Alex") @@ -748,7 +759,7 @@ class TestAge4ProjectionArithmeticIsSelfConsistent: rules = settings.rules expected_cap = rules.max_savable_days_at_age_4 - expected_projected_left = rules.days_per_parent + expected_projected_left = rules.total_days_per_child expected_expiring = max(expected_projected_left - expected_cap, 0) expected_savable = min(expected_projected_left, expected_cap) @@ -846,22 +857,22 @@ class TestWorkspaceParentsRenameAndRemove: assert remove_self.status_code == 422, remove_self.text assert remove_self.json()["detail"]["message_key"] == "workspace.parents.remove.self" - # --- remove: refused while dependent data exists ------------------- - await _set_vacation(client, headers, UUID(placeholder_uuid), ws_id, date.today().year) - - remove_blocked = await client.delete( + # --- remove: happy path, on an unencumbered placeholder ------------ + # Done here (before any dependent data exists on Samuel) rather than + # via a third placeholder, since the workspace is capped at 2 + # parents — see the cap test below. + delete_resp = await client.delete( f"/api/workspace/{ws_id}/parents/{placeholder_uuid}", headers=headers ) - assert remove_blocked.status_code == 422, remove_blocked.text - blocked_detail = remove_blocked.json()["detail"] - assert blocked_detail["message_key"] == "workspace.parents.remove.hasDependentData" - assert "vacation_days" in blocked_detail["data"]["blockers"] + assert delete_resp.status_code == 204, delete_resp.text + assert delete_resp.content == b"" - # Still a member — never silently removed despite the refusal. members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) - assert placeholder_uuid in {m["uuid"] for m in members_resp.json()} + member_uuids = {m["uuid"] for m in members_resp.json()} + assert placeholder_uuid not in member_uuids + assert member_uuids == {str(pd_uuid)} - # --- remove: happy path, on a second (unencumbered) placeholder -- + # --- add: a second placeholder, to re-fill the workspace ----------- add_resp2 = await client.post( f"/api/workspace/{ws_id}/parents", headers=headers, @@ -870,18 +881,36 @@ class TestWorkspaceParentsRenameAndRemove: assert add_resp2.status_code == 201, add_resp2.text other_placeholder_uuid = add_resp2.json()["uuid"] - delete_resp = await client.delete( - f"/api/workspace/{ws_id}/parents/{other_placeholder_uuid}", headers=headers + # --- add: capped at 2 parents per workspace ------------------------ + # Alex + Kim already fill the workspace's 2-parent cap, so a third + # add is refused (and never persisted) before any dependent-data + # concern is even relevant. + cap_resp = await client.post( + f"/api/workspace/{ws_id}/parents", + headers=headers, + json={"display_name": "Robin"}, ) - assert delete_resp.status_code == 204, delete_resp.text - assert delete_resp.content == b"" + assert cap_resp.status_code == 422, cap_resp.text + assert cap_resp.json()["detail"]["message_key"] == "workspace.parents.add.maxReached" members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) member_uuids = {m["uuid"] for m in members_resp.json()} - assert other_placeholder_uuid not in member_uuids - # The blocked placeholder and the caller are both still members. - assert placeholder_uuid in member_uuids - assert str(pd_uuid) in member_uuids + assert member_uuids == {other_placeholder_uuid, str(pd_uuid)} + + # --- remove: refused while dependent data exists ------------------- + await _set_vacation(client, headers, UUID(other_placeholder_uuid), ws_id, date.today().year) + + remove_blocked = await client.delete( + f"/api/workspace/{ws_id}/parents/{other_placeholder_uuid}", headers=headers + ) + assert remove_blocked.status_code == 422, remove_blocked.text + blocked_detail = remove_blocked.json()["detail"] + assert blocked_detail["message_key"] == "workspace.parents.remove.hasDependentData" + assert "vacation_days" in blocked_detail["data"]["blockers"] + + # Still a member — never silently removed despite the refusal. + members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) + assert other_placeholder_uuid in {m["uuid"] for m in members_resp.json()} class TestLeaveWishNameMessageKeyRoundTrip: diff --git a/backend/tests/unit/test_authz.py b/backend/tests/unit/test_authz.py index c93455c..d38615b 100644 --- a/backend/tests/unit/test_authz.py +++ b/backend/tests/unit/test_authz.py @@ -596,11 +596,10 @@ class TestForeignWorkspaceIdIs404: "workspace_id": str(two_workspaces.ws_b.id), "child_id": str(two_workspaces.child_a.id), "total_days": 480, - "days_used": 0, + "sjukpenning_left": 390, + "lagsta_niva_left": 90, + "dubbeldagar_left": 60, "reserved_days_left": 90, - "transferable_days_left": 150, - "sjukpenning_used": 0, - "lagsta_niva_used": 0, }, ) assert resp.status_code == 404 diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py index eb57015..c8b5df0 100644 --- a/backend/tests/unit/test_config.py +++ b/backend/tests/unit/test_config.py @@ -39,3 +39,13 @@ class TestProductionJwtSecretValidation: def test_default_log_level_is_info(self) -> None: settings = Settings() assert settings.log_level == "INFO" + + +class TestScraperDebugDumpOnSuccess: + def test_defaults_to_false(self) -> None: + settings = Settings() + assert settings.scraper_debug_dump_on_success is False + + def test_can_be_enabled(self) -> None: + settings = Settings(scraper_debug_dump_on_success=True) + assert settings.scraper_debug_dump_on_success is True diff --git a/backend/tests/unit/test_db_unit_of_work.py b/backend/tests/unit/test_db_unit_of_work.py index e4efbf7..efc847c 100644 --- a/backend/tests/unit/test_db_unit_of_work.py +++ b/backend/tests/unit/test_db_unit_of_work.py @@ -45,8 +45,8 @@ async def _workspace_count(conn: aiosqlite.Connection) -> int: async def _insert_workspace(conn: aiosqlite.Connection, workspace_id: str) -> None: await conn.execute( - "INSERT INTO workspace (id, name, planning_mode, created_at) VALUES (?, ?, ?, ?)", - (workspace_id, "Test", "solo", datetime.now().isoformat()), + "INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)", + (workspace_id, "Test", datetime.now().isoformat()), ) diff --git a/backend/tests/unit/test_default_tier_pool_share.py b/backend/tests/unit/test_default_tier_pool_share.py new file mode 100644 index 0000000..7905814 --- /dev/null +++ b/backend/tests/unit/test_default_tier_pool_share.py @@ -0,0 +1,36 @@ +"""Tests for `default_tier_pool_share` — the assumed per-parent tier share +used when no FK snapshot exists yet. + +See its docstring in `app/utils/tip_predicates.py` and the "Bug A" history: +`AllocationInputLoader.load` and `PlanningService._tier_pool_left` must never +derive this differently, which is why it takes an explicit `parent_count` +now instead of always halving (a sole parent gets the whole pool, not half +of it). +""" + +from app.config import settings +from app.utils.tip_predicates import default_tier_pool_share + + +class TestDefaultTierPoolShare: + def test_sole_parent_gets_the_full_pool(self) -> None: + sjuk, lagsta = default_tier_pool_share(settings.rules, parent_count=1) + assert (sjuk, lagsta) == (390, 90) + + def test_zero_parents_is_treated_as_a_sole_parent(self) -> None: + """Defensive: an empty workspace (shouldn't normally happen) still + gets a sane default rather than a divide/half-of-nothing edge case.""" + sjuk, lagsta = default_tier_pool_share(settings.rules, parent_count=0) + assert (sjuk, lagsta) == (390, 90) + + def test_two_parents_split_the_pool_in_half(self) -> None: + sjuk, lagsta = default_tier_pool_share(settings.rules, parent_count=2) + assert (sjuk, lagsta) == (195, 45) + + def test_more_than_two_parents_still_splits_in_half(self) -> None: + """The domain model caps workspaces at 2 parents (see + `WorkspaceService.add_placeholder_parent`), but this helper doesn't + itself need to assume that cap holds -- any partnered case (>= 2) + gets the halved share, matching FK's own per-parent allocation.""" + sjuk, lagsta = default_tier_pool_share(settings.rules, parent_count=3) + assert (sjuk, lagsta) == (195, 45) diff --git a/backend/tests/unit/test_fetch_data_router.py b/backend/tests/unit/test_fetch_data_router.py index 37608be..6e3f084 100644 --- a/backend/tests/unit/test_fetch_data_router.py +++ b/backend/tests/unit/test_fetch_data_router.py @@ -28,14 +28,20 @@ non-headless browser as a fire-and-forget background task, which is unsafe to do from a router test). """ +from datetime import datetime from typing import Any -from uuid import UUID +from uuid import UUID, uuid4 import pytest import pytest_asyncio from httpx import AsyncClient +from app.entities.parental_leave import ParentalLeaveFetch +from app.entities.personal_data import PersonalData +from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.routers import fetch_data as fetch_data_router +from app.scrapers.fk_scraper import PartnerDeduction +from app.services.leave_snapshot import build_snapshot from tests.conftest import SeededIds @@ -53,16 +59,11 @@ def _manual_payload(seeded_ids: SeededIds, **overrides: object) -> dict: "personal_data_uuid": str(seeded_ids.personal_data_uuid), "workspace_id": str(seeded_ids.workspace_id), "child_id": str(seeded_ids.child_id), - "total_days": 480, - "days_used": 100, - "reserved_days_total": 90, - "reserved_days_left": 90, - "transferable_days_left": 150, + "sjukpenning_left": 310, + "lagsta_niva_left": 70, "dubbeldagar_left": 60, - "sjukpenning_total": 390, - "sjukpenning_used": 80, - "lagsta_niva_total": 90, - "lagsta_niva_used": 20, + "reserved_days_left": 90, + "total_days": 480, } base.update(overrides) return base @@ -87,22 +88,28 @@ class TestSaveManualEntry: ) -> None: response = await client.post( "/api/parental-leave/manual", - json=_manual_payload(seeded_ids, reserved_days_total=90, reserved_days_left=91), + json=_manual_payload(seeded_ids, reserved_days_left=91), ) assert response.status_code == 422 - assert "reserved_days_left" in response.json()["detail"] + assert "reserved_days_left" in str(response.json()["detail"]) - async def test_days_used_exceeding_total_days_returns_422( + async def test_sjukpenning_left_exceeding_total_returns_422( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: response = await client.post( "/api/parental-leave/manual", - json=_manual_payload(seeded_ids, total_days=100, days_used=150), + json=_manual_payload( + seeded_ids, + total_days=240, + sjukpenning_left=196, + lagsta_niva_left=0, + reserved_days_left=0, + ), ) assert response.status_code == 422 - assert "days_used" in response.json()["detail"] + assert "sjukpenning_left" in str(response.json()["detail"]) async def test_out_of_range_field_is_rejected_by_schema_before_the_service( self, client: AsyncClient, seeded_ids: SeededIds @@ -117,6 +124,96 @@ class TestSaveManualEntry: assert response.status_code == 422 + async def test_deduced_source_round_trips( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await client.post( + "/api/parental-leave/manual", + json=_manual_payload(seeded_ids, source="deduced"), + ) + + assert response.status_code == 201 + assert response.json()["source"] == "deduced" + + async def test_omitted_total_days_defaults_to_full_pool_for_a_sole_parent( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + """`seeded_ids`'s workspace has exactly one member (the caller).""" + payload = _manual_payload(seeded_ids, sjukpenning_left=390, lagsta_niva_left=90) + del payload["total_days"] + + response = await client.post("/api/parental-leave/manual", json=payload) + + assert response.status_code == 201 + assert response.json()["total_days"] == 480 + + async def test_omitted_total_days_defaults_to_half_pool_with_a_partner( + self, + client: AsyncClient, + seeded_ids: SeededIds, + personal_data_repo: SQLitePersonalDataRepository, + ) -> None: + await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=seeded_ids.workspace_id, + name="Partner", + created_at=datetime.now(), + ) + ) + payload = _manual_payload(seeded_ids, sjukpenning_left=195, lagsta_niva_left=45) + del payload["total_days"] + + response = await client.post("/api/parental-leave/manual", json=payload) + + assert response.status_code == 201 + assert response.json()["total_days"] == 240 + + async def test_old_payload_shape_is_rejected( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + """The pre-refactor payload shape (used-based fields, no per-tier + "left") is missing the now-required `sjukpenning_left`/ + `lagsta_niva_left`/`dubbeldagar_left` fields and must 422, not be + silently reinterpreted. + """ + old_payload = { + "personal_data_uuid": str(seeded_ids.personal_data_uuid), + "workspace_id": str(seeded_ids.workspace_id), + "child_id": str(seeded_ids.child_id), + "total_days": 480, + "days_used": 100, + "reserved_days_total": 90, + "reserved_days_left": 90, + "transferable_days_left": 150, + "sjukpenning_total": 390, + "sjukpenning_used": 80, + "lagsta_niva_total": 90, + "lagsta_niva_used": 20, + } + + response = await client.post("/api/parental-leave/manual", json=old_payload) + + assert response.status_code == 422 + + async def test_legacy_field_names_alongside_a_valid_payload_are_ignored( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + """Unknown extra fields (the old field names) don't break a + request that is otherwise valid under the new schema — Pydantic + drops them rather than erroring.""" + payload = _manual_payload(seeded_ids) + payload["days_used"] = 999 + payload["sjukpenning_total"] = 999 + payload["transferable_days_left"] = 999 + + response = await client.post("/api/parental-leave/manual", json=payload) + + assert response.status_code == 201 + body = response.json() + assert body["days_left"] == 380 + assert body["tier_data"]["sjukpenning_total"] == 390 + class TestGetLatestAndHistory: async def test_get_latest_with_no_snapshot_returns_null( @@ -147,10 +244,10 @@ class TestGetLatestAndHistory: self, client: AsyncClient, seeded_ids: SeededIds ) -> None: await client.post( - "/api/parental-leave/manual", json=_manual_payload(seeded_ids, days_used=50) + "/api/parental-leave/manual", json=_manual_payload(seeded_ids, dubbeldagar_left=50) ) await client.post( - "/api/parental-leave/manual", json=_manual_payload(seeded_ids, days_used=60) + "/api/parental-leave/manual", json=_manual_payload(seeded_ids, dubbeldagar_left=60) ) response = await client.get( @@ -159,7 +256,7 @@ class TestGetLatestAndHistory: assert response.status_code == 200 body = response.json() assert len(body) == 2 - assert [row["days_used"] for row in body] == [60, 50] + assert [row["dubbeldagar_left"] for row in body] == [60, 50] class TestScraperEndpointsDisabled: @@ -287,3 +384,146 @@ class TestScraperEndpointsEnabled: assert response.status_code == 200 assert response.json() == {"status": "cancelled"} assert cancelled["session_id"] == "some-session-id" + + +class TestPersistCallbackPartnerDeduction: + """`start_fetch`'s `_persist` closure -- captured off a faked + `start_session` and invoked directly, exactly as `_do_extract` would + call it after a real scrape. Exercises the real dependency-injected + repos through the app, not a bare unit call, so a wiring regression + (wrong workspace_id/child_id) is caught too. + """ + + async def _capture_persist_callback( + self, + client: AsyncClient, + seeded_ids: SeededIds, + monkeypatch: pytest.MonkeyPatch, + ) -> Any: + monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", True) + captured: dict[str, Any] = {} + + async def fake_start_session( + *, + workspace_id: UUID, + personal_data_uuid: UUID, + child_id: UUID, + persist_callback: Any, + owner_user_id: UUID, + ) -> str: + captured["persist_callback"] = persist_callback + return "fake-session-id" + + monkeypatch.setattr(fetch_data_router._scraper_service, "start_session", fake_start_session) + + response = await client.post( + "/api/fetch-data/start", + json={ + "workspace_id": str(seeded_ids.workspace_id), + "personal_data_uuid": str(seeded_ids.personal_data_uuid), + "child_id": str(seeded_ids.child_id), + }, + ) + assert response.status_code == 200 + return captured["persist_callback"] + + def _fetch_and_deduction( + self, seeded_ids: SeededIds + ) -> tuple[ParentalLeaveFetch, PartnerDeduction]: + fetch = build_snapshot( + personal_data_uuid=seeded_ids.personal_data_uuid, + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + source="scraped", + total_days=240, + sjukpenning_left=60, + lagsta_niva_left=40, + dubbeldagar_left=20, + ) + deduction = PartnerDeduction( + total_days=240, + days_left=150, + sjukpenning_left=130, + lagsta_niva_left=20, + dubbeldagar_left=20, + ) + return fetch, deduction + + async def test_two_members_creates_a_deduced_snapshot_for_the_other_member( + self, + client: AsyncClient, + seeded_ids: SeededIds, + personal_data_repo: SQLitePersonalDataRepository, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + partner = await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=seeded_ids.workspace_id, + name="Partner", + created_at=datetime.now(), + ) + ) + persist_callback = await self._capture_persist_callback(client, seeded_ids, monkeypatch) + fetch, deduction = self._fetch_and_deduction(seeded_ids) + + await persist_callback(fetch, deduction) + + response = await client.get(f"/api/parental-leave/{partner.uuid}/{seeded_ids.child_id}") + assert response.status_code == 200 + body = response.json() + assert body is not None + assert body["source"] == "deduced" + assert body["days_left"] == 150 + + async def test_single_member_workspace_creates_only_the_callers_own_snapshot( + self, + client: AsyncClient, + seeded_ids: SeededIds, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + persist_callback = await self._capture_persist_callback(client, seeded_ids, monkeypatch) + fetch, deduction = self._fetch_and_deduction(seeded_ids) + + await persist_callback(fetch, deduction) + + response = await client.get( + f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}/history" + ) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["source"] == "scraped" + + async def test_partner_persist_failure_does_not_fail_the_session( + self, + client: AsyncClient, + seeded_ids: SeededIds, + personal_data_repo: SQLitePersonalDataRepository, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=seeded_ids.workspace_id, + name="Partner", + created_at=datetime.now(), + ) + ) + persist_callback = await self._capture_persist_callback(client, seeded_ids, monkeypatch) + fetch, deduction = self._fetch_and_deduction(seeded_ids) + + async def failing_persist_partner_deduction(**kwargs: Any) -> None: + raise RuntimeError("DB unavailable") + + monkeypatch.setattr( + fetch_data_router, "persist_partner_deduction", failing_persist_partner_deduction + ) + + await persist_callback(fetch, deduction) # must not raise + + # The caller's own snapshot is unaffected by the partner-persist failure. + response = await client.get( + f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}" + ) + assert response.status_code == 200 + assert response.json()["source"] == "scraped" diff --git a/backend/tests/unit/test_fk_scraper.py b/backend/tests/unit/test_fk_scraper.py index 741e2b0..7e8b797 100644 --- a/backend/tests/unit/test_fk_scraper.py +++ b/backend/tests/unit/test_fk_scraper.py @@ -24,12 +24,17 @@ import pytest from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.scrapers.fk_scraper import ( + FamilySummary, FKScraperService, - _SessionState, + PartnerDeduction, + _deduce_partner, _do_extract, + _extract_family_summary, + _SessionState, _set_status, _sessions, ) +from app.utils.errors import ValidationError # --------------------------------------------------------------------------- # Helpers for the HTML-fixture tests @@ -61,6 +66,40 @@ def _make_mock_page(html: str): and dependency-free. """ NIVA_SEL = "[data-test='forDig'] .visa-dagar__desktop .niva-statistik__dagar span" + FAMILY_SEL = "[data-test='forDigOchAnnanForalder']" + FAMILY_KVAR_SEL = FAMILY_SEL + " .visa-dagar__dagar-kvar" + FAMILY_DISCRETE_SEL = FAMILY_SEL + " .visa-dagar__discrete" + FAMILY_NIVA_SEL = FAMILY_SEL + " .visa-dagar__desktop .niva-statistik__dagar span" + + def _family_panel_present() -> bool: + return 'data-test="forDigOchAnnanForalder"' in html + + def _family_kvar() -> str | None: + m = re.search( + r'data-test="forDigOchAnnanForalder".*?' + r'class="visa-dagar__dagar-kvar">(\d+)<', + html, + re.DOTALL, + ) + return m.group(1) if m else None + + def _family_discrete() -> str | None: + m = re.search( + r'data-test="forDigOchAnnanForalder".*?' + r'class="visa-dagar__discrete">(.*?)<', + html, + re.DOTALL, + ) + return m.group(1) if m else None + + def _family_niva_all_texts() -> list[str]: + m_block = re.search( + r'data-test="forDigOchAnnanForalder"(.*?)$', + html, + re.DOTALL, + ) + block = m_block.group(1) if m_block else "" + return re.findall(r"\(\d+ dagar\)", block) def _for_dig_kvar() -> str: m = re.search( @@ -114,22 +153,41 @@ def _make_mock_page(html: str): loc.first.inner_text = AsyncMock(return_value="") loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=_niva_all_texts()) + loc.count = AsyncMock(return_value=len(_niva_all_texts())) + elif sel == FAMILY_SEL: + loc.count = AsyncMock(return_value=1 if _family_panel_present() else 0) + elif sel == FAMILY_KVAR_SEL: + val = _family_kvar() + loc.count = AsyncMock(return_value=1 if val is not None else 0) + loc.first.inner_text = AsyncMock(return_value=val or "") + elif sel == FAMILY_DISCRETE_SEL: + val = _family_discrete() + loc.count = AsyncMock(return_value=1 if val is not None else 0) + loc.first.inner_text = AsyncMock(return_value=val or "") + elif sel == FAMILY_NIVA_SEL: + texts = _family_niva_all_texts() + loc.count = AsyncMock(return_value=len(texts)) + loc.all_inner_texts = AsyncMock(return_value=texts) elif "visa-dagar__dagar-kvar" in sel and "forDig" in sel: loc.first.inner_text = AsyncMock(return_value=_for_dig_kvar()) loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) + loc.count = AsyncMock(return_value=1) elif "visa-dagar__discrete" in sel and "forDig" in sel: loc.first.inner_text = AsyncMock(return_value=_for_dig_discrete()) loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) + loc.count = AsyncMock(return_value=1) elif "dubbeldag" in sel and "visa-dagar__dagar-kvar" in sel: loc.first.inner_text = AsyncMock(return_value=_dubbeldag_kvar()) loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) + loc.count = AsyncMock(return_value=1) else: loc.first.inner_text = AsyncMock(return_value="") loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) + loc.count = AsyncMock(return_value=0) return loc page.locator.side_effect = locator_factory @@ -307,9 +365,23 @@ class TestValidation: owner_user_id=uuid.uuid4(), ) - async def test_negative_days_left_raises(self) -> None: - page = self._make_bad_page(-1, 240, 100, 45, 39) - with pytest.raises(ValueError, match="negative"): + async def test_negative_dubbeldagar_left_raises(self) -> None: + """Regression coverage moved here from a "negative days_left" scenario. + + Derivation now lives in `app.services.leave_snapshot.build_snapshot` + (see its module docstring), which derives `days_left` from the tier + breakdown (`sjukpenning_left + lagsta_niva_left`) rather than the + page's separate "För dig, dagar kvar" text — and that breakdown is + parsed with a digits-only regex, so it can never itself go negative. + `dubbeldagar_left` is still parsed with a plain `int()`, so it's the + remaining reachable path to a negative-number guard here; the + `days_left`/tier negative-value guards themselves are unit-tested + directly against `build_snapshot` in `test_leave_snapshot.py`. + `build_snapshot` raises `ValidationError` (a `DomainError`), not a + bare `ValueError` like this function's own page-parsing code does. + """ + page = self._make_bad_page(164, 240, 119, 45, -1) + with pytest.raises(ValidationError, match="negative"): await _extract_parental_leave(page, self._state()) async def test_total_days_parse_failure_raises(self) -> None: @@ -383,6 +455,193 @@ class TestValidation: await _extract_parental_leave(page, self._state()) +# --------------------------------------------------------------------------- +# Family panel -> partner deduction +# --------------------------------------------------------------------------- + +_NO_FAMILY_PANEL_HTML = """ +
+
+ 100 + av 240 dagar kvar efter ansökan +
+ (60 dagar) + (40 dagar) +
+
+
+
+
+ 20 +
+""" + +_FAMILY_PANEL_NO_TIERS_HTML = """ +
+
+ 100 + av 240 dagar kvar efter ansökan +
+ (60 dagar) + (40 dagar) +
+
+
+
+
+
+ 250 + av 480 dagar kvar efter ansökan +
+
+
+ 20 +
+""" + + +def _state() -> _SessionState: + return _SessionState( + session_id="family-test", + workspace_id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + child_id=uuid.uuid4(), + owner_user_id=uuid.uuid4(), + ) + + +class TestExtractFamilySummary: + """`_extract_family_summary` parses the (assumed) family-panel DOM defensively.""" + + async def test_family_summary_parsed_from_fixture(self) -> None: + html = _load_fixture("fk_overview_synthetic.html") + page = _make_mock_page(html) + family = await _extract_family_summary(page, _state()) + + assert family == FamilySummary( + total_days=480, days_left=250, sjukpenning_left=190, lagsta_niva_left=60 + ) + + async def test_absent_family_panel_returns_none(self) -> None: + page = _make_mock_page(_NO_FAMILY_PANEL_HTML) + family = await _extract_family_summary(page, _state()) + assert family is None + + async def test_absent_family_panel_does_not_affect_main_extraction(self) -> None: + """The caller's own extraction must succeed exactly as without this feature.""" + page = _make_mock_page(_NO_FAMILY_PANEL_HTML) + result = await _extract_parental_leave(page, _state()) + assert result.days_left == 100 + assert result.total_days == 240 + + async def test_tierless_family_panel_returns_summary_with_none_tiers(self) -> None: + page = _make_mock_page(_FAMILY_PANEL_NO_TIERS_HTML) + family = await _extract_family_summary(page, _state()) + + assert family == FamilySummary( + total_days=480, days_left=250, sjukpenning_left=None, lagsta_niva_left=None + ) + + +class TestDeducePartner: + """`_deduce_partner` derives the other parent's balance as family - mine.""" + + def _mine(self, **overrides: object) -> ParentalLeaveFetch: + now = datetime.now() + base = dict( + id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + workspace_id=uuid.uuid4(), + child_id=uuid.uuid4(), + source="scraped", + fetched_at=now, + total_days=240, + days_used=140, + days_left=100, + reserved_days_total=90, + reserved_days_left=90, + transferable_days_left=10, + dubbeldagar_left=20, + tier_data=TierData( + sjukpenning_total=195, + sjukpenning_used=135, + sjukpenning_left=60, + lagsta_niva_total=45, + lagsta_niva_used=5, + lagsta_niva_left=40, + ), + created_at=now, + updated_at=now, + ) + base.update(overrides) + return ParentalLeaveFetch(**base) + + def test_deduction_with_family_tiers_present(self) -> None: + mine = self._mine() + family = FamilySummary( + total_days=480, days_left=250, sjukpenning_left=190, lagsta_niva_left=60 + ) + + deduction = _deduce_partner(mine, family) + + assert deduction == PartnerDeduction( + total_days=240, + days_left=150, + sjukpenning_left=130, + lagsta_niva_left=20, + dubbeldagar_left=20, + ) + + def test_deduction_falls_back_to_proportional_split_without_family_tiers(self) -> None: + mine = self._mine() + family = FamilySummary( + total_days=480, days_left=250, sjukpenning_left=None, lagsta_niva_left=None + ) + + deduction = _deduce_partner(mine, family) + + # total=240, left=150; lagsta_share = 90*240 // 480 = 45 + assert deduction == PartnerDeduction( + total_days=240, + days_left=150, + sjukpenning_left=105, + lagsta_niva_left=45, + dubbeldagar_left=20, + ) + + def test_family_left_below_mine_returns_none(self) -> None: + """family.days_left < mine.days_left can only mean stale/inconsistent data.""" + mine = self._mine(total_days=240, days_left=200) + family = FamilySummary( + total_days=480, days_left=150, sjukpenning_left=None, lagsta_niva_left=None + ) + + assert _deduce_partner(mine, family) is None + + def test_family_total_not_greater_than_mine_returns_none(self) -> None: + mine = self._mine(total_days=480) + family = FamilySummary( + total_days=480, days_left=300, sjukpenning_left=None, lagsta_niva_left=None + ) + + assert _deduce_partner(mine, family) is None + + def test_negative_tier_deduction_falls_back_to_proportional_split(self) -> None: + """Family-level tiers that would go negative per-parent fall back rather + than producing a nonsensical negative PartnerDeduction field.""" + mine = self._mine() + # family.sjukpenning_left (50) < mine.sjukpenning_left (60) -> negative naive diff + family = FamilySummary( + total_days=480, days_left=150, sjukpenning_left=50, lagsta_niva_left=100 + ) + + deduction = _deduce_partner(mine, family) + + assert deduction is not None + assert deduction.sjukpenning_left >= 0 + assert deduction.lagsta_niva_left >= 0 + + # --------------------------------------------------------------------------- # Session state machine # --------------------------------------------------------------------------- @@ -418,6 +677,99 @@ class TestSessionStateMachine: assert result["status"] == "error" assert "Unknown" in result["message"] + async def test_poll_done_includes_partner_deduction_when_present(self) -> None: + service = FKScraperService() + sid = str(uuid.uuid4()) + owner_user_id = uuid.uuid4() + now = datetime.now() + result_fetch = ParentalLeaveFetch( + id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + workspace_id=uuid.uuid4(), + child_id=uuid.uuid4(), + source="scraped", + fetched_at=now, + total_days=240, + days_used=140, + days_left=100, + reserved_days_total=90, + reserved_days_left=90, + transferable_days_left=10, + dubbeldagar_left=20, + tier_data=TierData(195, 135, 60, 45, 5, 40), + created_at=now, + updated_at=now, + ) + deduction = PartnerDeduction( + total_days=240, + days_left=150, + sjukpenning_left=130, + lagsta_niva_left=20, + dubbeldagar_left=20, + ) + state = _SessionState( + session_id=sid, + workspace_id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + child_id=uuid.uuid4(), + owner_user_id=owner_user_id, + status="done", + result=result_fetch, + partner_deduction=deduction, + ) + _sessions[sid] = state + + result = await service.poll_session(sid, owner_user_id) + + assert result["status"] == "done" + assert result["partner_deduction"] == { + "total_days": 240, + "days_left": 150, + "sjukpenning_left": 130, + "lagsta_niva_left": 20, + "dubbeldagar_left": 20, + } + + async def test_poll_done_partner_deduction_is_none_when_absent(self) -> None: + service = FKScraperService() + sid = str(uuid.uuid4()) + owner_user_id = uuid.uuid4() + now = datetime.now() + result_fetch = ParentalLeaveFetch( + id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + workspace_id=uuid.uuid4(), + child_id=uuid.uuid4(), + source="scraped", + fetched_at=now, + total_days=480, + days_used=0, + days_left=480, + reserved_days_total=90, + reserved_days_left=90, + transferable_days_left=390, + dubbeldagar_left=60, + tier_data=TierData(390, 0, 390, 90, 0, 90), + created_at=now, + updated_at=now, + ) + state = _SessionState( + session_id=sid, + workspace_id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + child_id=uuid.uuid4(), + owner_user_id=owner_user_id, + status="done", + result=result_fetch, + partner_deduction=None, + ) + _sessions[sid] = state + + result = await service.poll_session(sid, owner_user_id) + + assert result["status"] == "done" + assert result["partner_deduction"] is None + async def test_poll_awaiting_scan_returns_qr_data(self) -> None: service = FKScraperService() sid = str(uuid.uuid4()) @@ -656,7 +1008,7 @@ class TestSessionStateMachine: """_do_extract invokes the persist_callback with the extracted result.""" persisted: list[ParentalLeaveFetch] = [] - async def mock_persist(fetch: ParentalLeaveFetch) -> None: + async def mock_persist(fetch: ParentalLeaveFetch, partner: PartnerDeduction | None) -> None: persisted.append(fetch) now = datetime.now() @@ -722,7 +1074,9 @@ class TestSessionStateMachine: async def test_do_extract_sets_error_when_persist_callback_fails(self) -> None: """_do_extract sets status=error if the persist_callback raises.""" - async def failing_persist(fetch: ParentalLeaveFetch) -> None: + async def failing_persist( + fetch: ParentalLeaveFetch, partner: PartnerDeduction | None + ) -> None: raise RuntimeError("DB unavailable") now = datetime.now() @@ -903,7 +1257,9 @@ class TestPersistenceIntegration: ) -> None: """After _do_extract completes, repo.get_latest_by_parent returns the scraped row.""" - async def persist_callback(fetch: ParentalLeaveFetch) -> None: + async def persist_callback( + fetch: ParentalLeaveFetch, partner: PartnerDeduction | None + ) -> None: await parental_leave_repo.create(fetch) now = datetime.now() diff --git a/backend/tests/unit/test_leave_wish_repository.py b/backend/tests/unit/test_leave_wish_repository.py index 01927b1..ff9cfd6 100644 --- a/backend/tests/unit/test_leave_wish_repository.py +++ b/backend/tests/unit/test_leave_wish_repository.py @@ -275,6 +275,42 @@ class TestSQLiteLeaveWishRepository: assert fetched is not None assert fetched.target_days is None + async def test_create_defaults_wants_dubbeldag_to_false( + self, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + entity = _make(workspace_id=seeded_ids.workspace_id) + created = await leave_wish_repo.create(entity) + assert created.wants_dubbeldag is False + + fetched = await leave_wish_repo.get_by_id(created.id) + assert fetched is not None + assert fetched.wants_dubbeldag is False + + async def test_create_and_update_round_trip_wants_dubbeldag( + self, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + entity = _make( + workspace_id=seeded_ids.workspace_id, parent_mode="both", wants_dubbeldag=True + ) + created = await leave_wish_repo.create(entity) + assert created.wants_dubbeldag is True + + fetched = await leave_wish_repo.get_by_id(created.id) + assert fetched is not None + assert fetched.wants_dubbeldag is True + + fetched.wants_dubbeldag = False + updated = await leave_wish_repo.update(fetched.id, fetched) + assert updated.wants_dubbeldag is False + + refetched = await leave_wish_repo.get_by_id(created.id) + assert refetched is not None + assert refetched.wants_dubbeldag is False + async def test_get_by_workspace( self, leave_wish_repo: SQLiteLeaveWishRepository, @@ -446,6 +482,7 @@ class TestLeaveWishService: assert result.target_weeks == 6 assert result.recurrence == "once" # default assert result.parent_mode == "either" # default + assert result.wants_dubbeldag is False # default @pytest.mark.parametrize("recurrence", ["once", "yearly"]) async def test_create_accepts_valid_recurrence( @@ -522,6 +559,11 @@ class TestLeaveWishService: "last_year", id="last_year_before_first_year", ), + pytest.param( + {"parent_mode": "either", "wants_dubbeldag": True}, + "wants_dubbeldag", + id="wants_dubbeldag_forbidden_unless_both", + ), ] @pytest.mark.parametrize("overrides,match", _REJECTION_CASES) @@ -578,6 +620,49 @@ class TestLeaveWishService: assert result.parent_mode == "specific" assert result.parent_personal_data_uuid == seeded_ids.personal_data_uuid + # -- wants_dubbeldag (§Decision A, opt-in co-leave) --------------------- + + async def test_create_both_parent_mode_can_opt_into_dubbeldagar( + self, + leave_wish_service: LeaveWishService, + seeded_ids: SeededIds, + ) -> None: + result = await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, + name="Wish", + window_start_month=1, + window_start_day=1, + window_end_month=1, + window_end_day=10, + target_weeks=1, + first_year=2027, + parent_mode="both", + wants_dubbeldag=True, + ) + assert result.parent_mode == "both" + assert result.wants_dubbeldag is True + + async def test_update_can_toggle_wants_dubbeldag( + self, + leave_wish_service: LeaveWishService, + seeded_ids: SeededIds, + ) -> None: + created = await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, + name="Wish", + window_start_month=1, + window_start_day=1, + window_end_month=1, + window_end_day=10, + target_weeks=1, + first_year=2027, + parent_mode="both", + ) + assert created.wants_dubbeldag is False + + updated = await leave_wish_service.update(leave_wish_id=created.id, wants_dubbeldag=True) + assert updated.wants_dubbeldag is True + # -- window_mode (§10.1) ------------------------------------------------ async def test_create_defaults_to_fixed_window_mode( diff --git a/backend/tests/unit/test_leave_wish_router.py b/backend/tests/unit/test_leave_wish_router.py index 67854db..0fcdbdf 100644 --- a/backend/tests/unit/test_leave_wish_router.py +++ b/backend/tests/unit/test_leave_wish_router.py @@ -282,3 +282,154 @@ class TestLeaveWishTargetDaysRoundTrip: listed = await client.get(f"/api/leave-wishes/{ws.id}") assert listed.status_code == 200 assert listed.json()[0]["target_days"] is None + + +class TestLeaveWishWantsDubbeldagRoundTrip: + """§Decision A (co-leave, opt-in dubbeldagar) — `wants_dubbeldag` round-trips + + through the router exactly like `target_days` above, and is rejected + when `parent_mode` isn't `'both'`. + """ + + async def test_create_defaults_to_false( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + await SQLitePersonalDataRepository(connection).create( + PersonalData( + uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now + ) + ) + + response = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": None, + "name": "Vårlov", + "window_start_month": 3, + "window_start_day": 1, + "window_end_month": 5, + "window_end_day": 31, + "target_weeks": 2, + "recurrence": "once", + "first_year": date.today().year + 1, + }, + ) + + assert response.status_code == 201 + assert response.json()["wants_dubbeldag"] is False + + async def test_create_with_both_parent_mode_can_opt_in( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + await SQLitePersonalDataRepository(connection).create( + PersonalData( + uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now + ) + ) + + response = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": None, + "name": "Vårlov", + "window_start_month": 3, + "window_start_day": 1, + "window_end_month": 5, + "window_end_day": 31, + "target_weeks": 2, + "recurrence": "once", + "first_year": date.today().year + 1, + "parent_mode": "both", + "wants_dubbeldag": True, + }, + ) + + assert response.status_code == 201 + assert response.json()["wants_dubbeldag"] is True + + async def test_create_rejects_wants_dubbeldag_outside_both_mode( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + await SQLitePersonalDataRepository(connection).create( + PersonalData( + uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now + ) + ) + + response = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": None, + "name": "Vårlov", + "window_start_month": 3, + "window_start_day": 1, + "window_end_month": 5, + "window_end_day": 31, + "target_weeks": 2, + "recurrence": "once", + "first_year": date.today().year + 1, + "wants_dubbeldag": True, # parent_mode defaults to 'either' + }, + ) + + assert response.status_code == 422 + + async def test_update_can_toggle_it( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + await SQLitePersonalDataRepository(connection).create( + PersonalData( + uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now + ) + ) + + create_response = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": None, + "name": "Vårlov", + "window_start_month": 3, + "window_start_day": 1, + "window_end_month": 5, + "window_end_day": 31, + "target_weeks": 2, + "recurrence": "once", + "first_year": date.today().year + 1, + "parent_mode": "both", + }, + ) + assert create_response.status_code == 201 + wish_id = create_response.json()["id"] + assert create_response.json()["wants_dubbeldag"] is False + + update_response = await client.put( + f"/api/leave-wishes/{wish_id}", + json={"wants_dubbeldag": True}, + ) + + assert update_response.status_code == 200 + assert update_response.json()["wants_dubbeldag"] is True diff --git a/backend/tests/unit/test_plan_router.py b/backend/tests/unit/test_plan_router.py index 8a2be65..ed5334c 100644 --- a/backend/tests/unit/test_plan_router.py +++ b/backend/tests/unit/test_plan_router.py @@ -940,6 +940,159 @@ def _assert_domain_fields_reach_the_wire( ) +class TestPlanSyncEvictionPreviewOverTheWire: + """`POST /api/plan/sync`'s `eviction_choice` field and `eviction_preview` + + response over the actual HTTP boundary -- the dataclass-level contract is + covered exhaustively by `tests/unit/test_planning_service_sync.py:: + TestSyncEvictionPreview`; this only needs to prove the wire shape + (`PlanSyncRequest.eviction_choice` / `PlanSyncResponse.eviction_preview`) + round-trips correctly and that an invalid choice 422s at the Pydantic + boundary rather than silently reaching the service as a no-op. + + Two non-overlapping windows sharing one sjukpenning pool (same recipe as + the service-level tests): "Existing" (Jan 11-15) syncs first and spends + the whole pool; "New" (Feb 1-5), added afterwards with higher priority, + steals it on the next sync. + """ + + async def test_sync_previews_then_resolves_an_eviction( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + personal_data_repo = SQLitePersonalDataRepository(connection) + child_repo = SQLiteChildRepository(connection) + parental_leave_repo = SQLiteParentalLeaveRepository(connection) + vacation_repo = SQLiteVacationDaysRepository(connection) + + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + parent = await personal_data_repo.create( + PersonalData( + uuid=_CURRENT_USER_PD_UUID, + workspace_id=ws.id, + name="Alex", + salary_type="monthly", + created_at=now, + ) + ) + child = await child_repo.create( + Child( + id=uuid4(), + workspace_id=ws.id, + name="Barn", + birth_date=date(2020, 1, 1), + created_at=now, + ) + ) + await vacation_repo.create( + VacationDays( + id=uuid4(), + personal_data_uuid=parent.uuid, + workspace_id=ws.id, + year=2027, + total_days=0, + ) + ) + await parental_leave_repo.create( + _make_fetch(parent.uuid, ws.id, child.id, sjukpenning_left=390, lagsta_left=0) + ) + + create_existing = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": str(child.id), + "name": "Existing", + "window_start_month": 1, + "window_start_day": 11, + "window_end_month": 1, + "window_end_day": 15, + "target_weeks": 1, + "recurrence": "once", + "first_year": 2027, + "priority": 5, + }, + ) + assert create_existing.status_code == 201 + + first = await client.post("/api/plan/sync", json={"workspace_id": str(ws.id)}) + assert first.status_code == 200 + first_body = first.json() + assert first_body["apply_result"]["accepted"] is True + assert first_body["eviction_preview"]["would_evict"] is False + existing_count = first_body["apply_result"]["created_count"] + assert existing_count > 0 + + # Shrink the shared pool to exactly what `Existing` already spent, + # then a higher-priority wish over a DIFFERENT window steals it. + await parental_leave_repo.create( + _make_fetch( + parent.uuid, ws.id, child.id, sjukpenning_left=existing_count, lagsta_left=0 + ) + ) + create_new = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": str(child.id), + "name": "New", + "window_start_month": 2, + "window_start_day": 1, + "window_end_month": 2, + "window_end_day": 5, + "target_weeks": 1, + "recurrence": "once", + "first_year": 2027, + "priority": 1, + }, + ) + assert create_new.status_code == 201 + + # An invalid choice 422s at the Pydantic boundary. + invalid = await client.post( + "/api/plan/sync", + json={"workspace_id": str(ws.id), "eviction_choice": "not_a_real_choice"}, + ) + assert invalid.status_code == 422 + + # No choice -> preview only, nothing accepted/mutated. + preview = await client.post("/api/plan/sync", json={"workspace_id": str(ws.id)}) + assert preview.status_code == 200 + preview_body = preview.json() + assert preview_body["apply_result"]["accepted"] is False + assert ( + preview_body["apply_result"]["rejections"][0]["message_key"] + == "planSync.rejection.evictionChoiceRequired" + ) + assert preview_body["eviction_preview"]["would_evict"] is True + candidates = preview_body["eviction_preview"]["candidates"] + assert candidates + assert all(c["wish_name"] == "Existing" for c in candidates) + assert all(c["day_type"] == "sjukpenning" for c in candidates) + shortages = preview_body["eviction_preview"]["shortages"] + assert len(shortages) == 1 + assert shortages[0]["resource_kind"] == "parental_leave" + assert shortages[0]["tier"] == "sjukpenning" + assert shortages[0]["child_id"] == str(child.id) + assert shortages[0]["parent_personal_data_uuid"] == str(parent.uuid) + + # The chosen option resolves it: the edit lands, `Existing`'s stale + # days are gone. + resolved = await client.post( + "/api/plan/sync", + json={"workspace_id": str(ws.id), "eviction_choice": "drop_future_pl"}, + ) + assert resolved.status_code == 200 + resolved_body = resolved.json() + assert resolved_body["apply_result"]["accepted"] is True + assert resolved_body["eviction_preview"]["would_evict"] is True + wish_ids_written = {d["wish_id"] for d in resolved_body["apply_result"]["created_days"]} + assert wish_ids_written == {create_new.json()["id"]} + + class TestPlanProposalFieldParityWithDomain: """Mechanism test: every `PlanProposal`/`WindowOutcome` field must survive the hand-written `PlanProposalResponse.from_domain()` mapping, not just diff --git a/backend/tests/unit/test_planning_service_apply.py b/backend/tests/unit/test_planning_service_apply.py index 3c262b9..d70a210 100644 --- a/backend/tests/unit/test_planning_service_apply.py +++ b/backend/tests/unit/test_planning_service_apply.py @@ -430,12 +430,15 @@ class TestApplyProposedDaysValidation: seeded_ids: SeededIds, ) -> None: """Bug A regression, third copy: with no FK snapshot at all, this - parent's default tier balance must be their per-parent SHARE (195 - sjukpenning / 45 lagsta) of `rules.sjukpenning_days`/`lagsta_niva_days` - (390/90, the WHOLE CHILD's combined total across both parents) — not - the full 390/90, which `AllocationInputLoader.load` also used to - wrongly assume for a single parent before the fix. A batch of exactly - 195 sjukpenning days must be accepted; one more must be rejected. + parent's default tier balance must agree with + `AllocationInputLoader.load`'s (both go through the shared + `default_tier_pool_share` helper — see its docstring). `seeded_ids`'s + workspace has exactly ONE member, so the default is the FULL + `rules.sjukpenning_days` (390) — the pool follows actual parent + count, not an unconditional halving (see `app.utils.tip_predicates. + default_tier_pool_share`: a sole parent gets the whole pool, a + partnered one gets half). A batch of exactly 390 sjukpenning days + must be accepted; one more must be rejected. """ ws, child, parent = ( seeded_ids.workspace_id, @@ -445,7 +448,7 @@ class TestApplyProposedDaysValidation: # No ParentalLeaveFetch created -> exercises the default fallback. one_too_many = [ ProposedDayInput(date(2026, 1, 1) + timedelta(days=i), parent, child, "sjukpenning") - for i in range(196) # 195 default share + 1 over + for i in range(391) # 390 default share (sole parent) + 1 over ] result = await planning_service.apply_proposed_days(ws, one_too_many) @@ -461,7 +464,7 @@ class TestApplyProposedDaysValidation: ) assert rows == [] - exactly_the_share = one_too_many[:195] + exactly_the_share = one_too_many[:390] result_ok = await planning_service.apply_proposed_days(ws, exactly_the_share) assert result_ok.accepted is True diff --git a/backend/tests/unit/test_planning_service_sync.py b/backend/tests/unit/test_planning_service_sync.py index 14a0b00..0557084 100644 --- a/backend/tests/unit/test_planning_service_sync.py +++ b/backend/tests/unit/test_planning_service_sync.py @@ -21,7 +21,11 @@ from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonal from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository from app.services.allocation_service import PlanningEngineService -from app.services.planning_service import PlanningService +from app.services.planning_service import ( + EVICTION_CHOICE_DROP_FUTURE_PL, + EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR, + PlanningService, +) from tests.conftest import SeededIds @@ -419,6 +423,547 @@ class TestSyncCreditsFreedCapacityWithinSameCall: assert any(r.day_type == "sjukpenning" for r in winter_rows) +class TestSyncEvictionPreview: + """`sync_plan`'s eviction preview/resolution — the fix for the data-loss + + bug where editing one wish silently deleted another wish's rows because + the shared pool went to the edited wish first (see this file's module + docstring / PLANNING_ENGINE_SPEC.md). Both scenarios below use two wishes + over the EXACT SAME window (Mon 11 - Fri 15 Jan 2027 -- five plain + workdays, no weekend, no Swedish red day in range) so a higher-priority + wish's fresh placement claims literally the same dates the lower-priority + wish already had persisted, guaranteeing full contention regardless of + the exact day-count the allocator computes. + """ + + async def test_would_evict_returns_preview_and_mutates_nothing( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + # No vacation this year, no lagsta -> the only lever is sjukpenning, + # so a shortfall there can never be silently backstopped by anything + # else (it must show up as a genuine shortfall/eviction). + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 + ) + ) + await parental_leave_repo.create( + _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0) + ) + + existing = await leave_wish_repo.create( + _make_wish( + ws, + child, + name="Existing", + window_start_month=1, + window_start_day=11, + window_end_month=1, + window_end_day=15, + priority=5, + ) + ) + + first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert first.apply_result.accepted is True + rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child) + existing_count = len(rows_after_first) + assert existing_count > 0 + assert all(r.wish_id == existing.id for r in rows_after_first) + assert first.eviction_preview.would_evict is False + + # Shrink the shared pool to exactly what `existing` already spent (no + # slack), then a higher-priority wish over the SAME window steals it. + await parental_leave_repo.create( + _make_fetch( + parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0 + ) + ) + await leave_wish_repo.create( + _make_wish( + ws, + child, + name="New", + window_start_month=2, + window_start_day=1, + window_end_month=2, + window_end_day=5, + priority=1, + ) + ) + + second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + + # Nothing accepted, nothing changed, and the caller must be told why. + assert second.apply_result.accepted is False + assert second.changes == [] + assert second.apply_result.rejections + assert ( + second.apply_result.rejections[0].message_key + == "planSync.rejection.evictionChoiceRequired" + ) + + preview = second.eviction_preview + assert preview.would_evict is True + assert preview.candidates, "expected the evicted 'Existing' days to be listed" + assert all(c.wish_id == existing.id for c in preview.candidates) + assert all(c.wish_name == "Existing" for c in preview.candidates) + assert all(c.day_type == "sjukpenning" for c in preview.candidates) + assert all( + c.child_id == child and c.parent_personal_data_uuid == parent + for c in preview.candidates + ) + + assert len(preview.shortages) == 1 + shortage = preview.shortages[0] + assert shortage.resource_kind == "parental_leave" + assert shortage.parent_personal_data_uuid == parent + assert shortage.tier == "sjukpenning" + assert shortage.child_id == child + assert shortage.year is None + assert shortage.days_evicted == len(preview.candidates) + + # Requirement: mutates NOTHING, not even the harmless parts. + rows_after_second = await planned_leave_repo.list_by_workspace_child(ws, child) + assert {r.id for r in rows_after_second} == {r.id for r in rows_after_first} + + async def test_shortfall_days_is_not_masked_when_a_higher_priority_wish_claims_the_same_dates( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """Regression for a real bug this eviction-preview work uncovered in + + `_Ledger._fund_solo` (`app/services/allocation_service.py`): when a + higher-priority wish's placement lands on the EXACT SAME calendar + dates a lower-priority wish already occupied (as opposed to merely + exhausting a shared pool on different dates — the scenario the test + above covers), `_fund_solo` used to find those dates already in + `_existing_lookup` (via `record_days()`, folded in by the earlier- + processed wish this same run) and credit them toward the LATER + wish's own `cost`/`shortfall_days` as if THAT wish had gotten them + too — so a wish entirely crowded out on every one of its dates could + still report `shortfall_days == 0`. Fixed by only crediting cost for + dates present in `_existing_lookup` BEFORE this run's own + `record_days()` calls started (`_pre_run_occupied`); a same-run + claim by a different wish now correctly falls through unfunded. + + NOTE — known, separately-scoped gap this test does NOT claim to fix: + because the two wishes here land on the identical (child, parent, + date) keys, `sync_plan`'s `stale_keys`/`_genuinely_evicted_rows` + (which only look at keys ABSENT from the fresh batch) never fire — + the row survives and is silently metadata-updated to the new wish's + `wish_id` (a 'reattributed' `PlanChange`, not a deletion), bypassing + `eviction_preview` entirely. That is a real, distinct hole in the + eviction-preview feature (same-date collisions between two DIFFERENT + wishes are not gated the way stale-key drops are) — flagged in this + session's handoff report as follow-up work, not fixed here. This + test only pins down that `shortfall_days`/`weeks_funded` — the + signal both the UI's "window funded" display AND + `_genuinely_evicted_rows` depend on — are no longer silently wrong. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 + ) + ) + await parental_leave_repo.create( + _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0) + ) + existing = await leave_wish_repo.create( + _make_wish( + ws, + child, + name="Existing", + window_start_month=1, + window_start_day=11, + window_end_month=1, + window_end_day=15, + priority=5, + ) + ) + first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert first.apply_result.accepted is True + existing_count = len(await planned_leave_repo.list_by_workspace_child(ws, child)) + + await parental_leave_repo.create( + _make_fetch( + parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0 + ) + ) + # SAME window as `existing` -- the higher-priority wish's placement + # lands on the literal same calendar dates, not merely a shared pool. + await leave_wish_repo.create( + _make_wish( + ws, + child, + name="New", + window_start_month=1, + window_start_day=11, + window_end_month=1, + window_end_day=15, + priority=1, + ) + ) + + second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + + existing_outcome = next(o for o in second.proposal.outcomes if o.wish_id == existing.id) + assert existing_outcome.shortfall_days == existing_count, ( + "the crowded-out wish's own outcome must report the real shortfall, " + "not silently claim the other wish's days as its own funded total" + ) + assert existing_outcome.weeks_funded == 0.0 + + async def test_drop_future_pl_choice_frees_the_days_and_lets_the_edit_land( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 + ) + ) + await parental_leave_repo.create( + _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0) + ) + existing = await leave_wish_repo.create( + _make_wish( + ws, + child, + name="Existing", + window_start_month=1, + window_start_day=11, + window_end_month=1, + window_end_day=15, + priority=5, + ) + ) + first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert first.apply_result.accepted is True + existing_count = len(await planned_leave_repo.list_by_workspace_child(ws, child)) + + await parental_leave_repo.create( + _make_fetch( + parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0 + ) + ) + new_wish = await leave_wish_repo.create( + _make_wish( + ws, + child, + name="New", + window_start_month=2, + window_start_day=1, + window_end_month=2, + window_end_day=5, + priority=1, + ) + ) + + preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert preview.eviction_preview.would_evict is True + + resolved = await planning_service.sync_plan( + ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL + ) + assert resolved.apply_result.accepted is True, resolved.apply_result.rejections + + rows_after = await planned_leave_repo.list_by_workspace_child(ws, child) + assert rows_after, "expected the new wish's days to have been written" + assert all(r.wish_id == new_wish.id for r in rows_after) + assert not any(r.wish_id == existing.id for r in rows_after) + + async def test_wrong_choice_still_rejects_and_mutates_nothing( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """The vacation-eviction option can't free a parental-leave shortage + + -- `apply_proposed_days`'s own balance validation rejects the whole + batch exactly as it would with no choice at all, atomically. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 + ) + ) + await parental_leave_repo.create( + _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0) + ) + await leave_wish_repo.create( + _make_wish( + ws, + child, + name="Existing", + window_start_month=1, + window_start_day=11, + window_end_month=1, + window_end_day=15, + priority=5, + ) + ) + first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert first.apply_result.accepted is True + rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child) + existing_count = len(rows_after_first) + + await parental_leave_repo.create( + _make_fetch( + parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0 + ) + ) + await leave_wish_repo.create( + _make_wish( + ws, + child, + name="New", + window_start_month=2, + window_start_day=1, + window_end_month=2, + window_end_day=5, + priority=1, + ) + ) + + resolved = await planning_service.sync_plan( + ws, + today=date(2027, 1, 1), + eviction_choice=EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR, + ) + assert resolved.apply_result.accepted is False + rows_after = await planned_leave_repo.list_by_workspace_child(ws, child) + assert {r.id for r in rows_after} == {r.id for r in rows_after_first} + + async def test_drop_latest_vacation_choice_frees_vacation_days( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + # No FP at all -> the ONLY way either wish's window can be funded is + # vacation, so a shortfall here can only ever be a vacation shortage. + await parental_leave_repo.create( + _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=0, lagsta_left=0) + ) + vacation_row = await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=25 + ) + ) + existing = await leave_wish_repo.create( + _make_wish( + ws, + child, + name="Existing", + window_start_month=1, + window_start_day=11, + window_end_month=1, + window_end_day=15, + priority=5, + ) + ) + first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert first.apply_result.accepted is True + rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child) + existing_count = len(rows_after_first) + assert existing_count > 0 + assert all(r.day_type == "vacation" for r in rows_after_first) + + # Shrink the shared vacation budget to exactly what `existing` spent. + vacation_row.total_days = existing_count + await vacation_repo.update(vacation_row.id, vacation_row) + new_wish = await leave_wish_repo.create( + _make_wish( + ws, + child, + name="New", + window_start_month=2, + window_start_day=1, + window_end_month=2, + window_end_day=5, + priority=1, + ) + ) + + preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert preview.eviction_preview.would_evict is True + assert all(c.day_type == "vacation" for c in preview.eviction_preview.candidates) + assert len(preview.eviction_preview.shortages) == 1 + shortage = preview.eviction_preview.shortages[0] + assert shortage.resource_kind == "vacation" + assert shortage.year == 2027 + assert shortage.tier is None + assert shortage.child_id is None + + # The wrong option (PL) can't touch a vacation shortage. + wrong = await planning_service.sync_plan( + ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL + ) + assert wrong.apply_result.accepted is False + rows_still_original = await planned_leave_repo.list_by_workspace_child(ws, child) + assert {r.id for r in rows_still_original} == {r.id for r in rows_after_first} + + resolved = await planning_service.sync_plan( + ws, + today=date(2027, 1, 1), + eviction_choice=EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR, + ) + assert resolved.apply_result.accepted is True, resolved.apply_result.rejections + rows_after = await planned_leave_repo.list_by_workspace_child(ws, child) + assert rows_after + assert all(r.wish_id == new_wish.id for r in rows_after) + assert not any(r.wish_id == existing.id for r in rows_after) + + async def test_pinned_day_never_evicted_under_any_choice( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 + ) + ) + await parental_leave_repo.create( + _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0) + ) + await leave_wish_repo.create( + _make_wish( + ws, + child, + name="Existing", + window_start_month=1, + window_start_day=11, + window_end_month=1, + window_end_day=15, + priority=5, + ) + ) + first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert first.apply_result.accepted is True + rows = await planned_leave_repo.list_by_workspace_child(ws, child) + assert rows + + to_pin = rows[0] + pinned = await planning_service.update(to_pin.id, is_pinned=True) + assert pinned.is_pinned is True + + await parental_leave_repo.create( + _make_fetch(parent, ws, child, datetime(2027, 1, 2), sjuk_left=len(rows), lagsta_left=0) + ) + await leave_wish_repo.create( + _make_wish( + ws, + child, + name="New", + window_start_month=2, + window_start_day=1, + window_end_month=2, + window_end_day=5, + priority=1, + ) + ) + + preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert preview.eviction_preview.would_evict is True + assert all(c.date != pinned.date for c in preview.eviction_preview.candidates) + + resolved = await planning_service.sync_plan( + ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL + ) + assert resolved.apply_result.accepted is True, resolved.apply_result.rejections + rows_after = await planned_leave_repo.list_by_workspace_child(ws, child) + survivor = next((r for r in rows_after if r.id == pinned.id), None) + assert survivor is not None, "the pinned day must never be evicted" + assert survivor.is_pinned is True + assert survivor.date == pinned.date + assert survivor.day_type == pinned.day_type + + +class TestSyncEvictionPreviewAbsentWhenWithinBudget: + async def test_plain_sync_has_no_preview_friction( + self, + planning_service: PlanningService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """Requirement 5 — a save that fits within budget applies straight + + through: no extra round trip, no empty-but-present friction either. + """ + ws, child, _parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await leave_wish_repo.create(_make_wish(ws, child)) + + result = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + + assert result.apply_result.accepted is True + assert result.eviction_preview.would_evict is False + assert result.eviction_preview.candidates == [] + assert result.eviction_preview.shortages == [] + + class TestProposeStaysReadOnly: async def test_propose_never_writes_a_row( self, diff --git a/backend/tests/unit/test_services/test_allocation_input_loader.py b/backend/tests/unit/test_services/test_allocation_input_loader.py index 225b4bc..e56aa54 100644 --- a/backend/tests/unit/test_services/test_allocation_input_loader.py +++ b/backend/tests/unit/test_services/test_allocation_input_loader.py @@ -4,8 +4,8 @@ planning engine (see app.services.allocation_service module docstring). Uses the same sqlite-backed fixtures as the rest of the service test suite (tests/conftest.py) to seed a realistic workspace and verify the loader nets out already-committed `planned_leave_day` rows, falls back to default -entitlement when no FK snapshot exists, and reads `planning_mode` off the -workspace. +entitlement when no FK snapshot exists, and resolves the age-4 savable-days +cap from parent count. """ from datetime import date, datetime @@ -91,7 +91,6 @@ class TestAllocationInputLoader: Workspace( id=uuid4(), name="Familj", - planning_mode="couple", created_at=now, ) ) @@ -200,10 +199,8 @@ class TestAllocationInputLoader: ) alloc_input = await loader.load(ws.id, today=date(2027, 1, 1)) - assert alloc_input.planning_mode == "couple" assert alloc_input.today == date(2027, 1, 1) - # Two parents in a 'couple' workspace -> per-parent age-4 cap (48), - # not the per-child figure (96). + # Two parents -> per-parent age-4 cap (48), not the per-child figure (96). assert alloc_input.age4_savable_cap == 48 pool_with_fetch = alloc_input.pools[(parent_with_fetch.uuid, child.id)] @@ -235,6 +232,123 @@ class TestAllocationInputLoader: assert parent_facts_by_uuid[parent_with_fetch.uuid].min_vacation_days_per_year == 20 assert parent_facts_by_uuid[parent_without_fetch.uuid].min_vacation_days_per_year == 20 + async def test_only_pinned_existing_hides_unpinned_wish_rows_but_all_existing_planned_keeps_them( + self, + workspace_repo: SQLiteWorkspaceRepository, + child_repo: SQLiteChildRepository, + personal_data_repo: SQLitePersonalDataRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + ) -> None: + """§fix (parent-reassignment moves dates, decision B) — `sync_plan` + + loads with `only_pinned_existing=True`, which must keep hiding + unpinned wish-attributed rows from `existing_planned` (unchanged + behaviour), but `all_existing_planned` must carry EVERY persisted + row regardless — that's what lets `_Ledger._persisted_by_wish` find + a flexible wish's own prior placement even when it's currently + hidden from `existing_planned`. See `AllocationInput + .all_existing_planned`'s docstring in allocation_service.py. + """ + now = datetime.now() + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + parent = await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=ws.id, + name="Parent", + salary_type="monthly", + employment_status="employed", + created_at=now, + ) + ) + child = await child_repo.create( + Child( + id=uuid4(), + workspace_id=ws.id, + name="Barn", + birth_date=date(2026, 1, 1), + created_at=now, + ) + ) + wish = await leave_wish_repo.create( + LeaveWish( + id=uuid4(), + workspace_id=ws.id, + child_id=child.id, + name="Höstlov", + window_start_month=10, + window_start_day=1, + window_end_month=10, + window_end_day=7, + target_weeks=1, + recurrence="once", + first_year=2027, + last_year=None, + priority=1, + parent_mode="either", + parent_personal_data_uuid=None, + window_mode="flexible", + ) + ) + + pinned = await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=ws.id, + child_id=child.id, + parent_personal_data_uuid=parent.uuid, + date=date(2027, 10, 1), + day_type="vacation", + is_pinned=True, + ) + ) + unpinned_wish_row = await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=ws.id, + child_id=child.id, + parent_personal_data_uuid=parent.uuid, + date=date(2027, 10, 4), + day_type="vacation", + wish_id=wish.id, + is_pinned=False, + ) + ) + ad_hoc = await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=ws.id, + child_id=child.id, + parent_personal_data_uuid=parent.uuid, + date=date(2027, 10, 5), + day_type="vacation", + wish_id=None, + is_pinned=False, + ) + ) + + loader = _make_loader( + workspace_repo, + child_repo, + personal_data_repo, + parental_leave_repo, + vacation_repo, + planned_leave_repo, + leave_wish_repo, + ) + alloc_input = await loader.load(ws.id, today=date(2027, 1, 1), only_pinned_existing=True) + + visible_ids = {d.id for d in alloc_input.existing_planned} + assert visible_ids == {pinned.id, ad_hoc.id} + assert unpinned_wish_row.id not in visible_ids + + assert alloc_input.all_existing_planned is not None + all_ids = {d.id for d in alloc_input.all_existing_planned} + assert all_ids == {pinned.id, unpinned_wish_row.id, ad_hoc.id} + async def test_load_reflects_a_custom_minimum_vacation_days( self, workspace_repo: SQLiteWorkspaceRepository, diff --git a/backend/tests/unit/test_services/test_allocation_once_and_wrap.py b/backend/tests/unit/test_services/test_allocation_once_and_wrap.py index 19e94b5..c102187 100644 --- a/backend/tests/unit/test_services/test_allocation_once_and_wrap.py +++ b/backend/tests/unit/test_services/test_allocation_once_and_wrap.py @@ -61,7 +61,6 @@ class TestOnceRecurrenceProducesDays: alloc_input = _build_input( today=date(2026, 9, 17), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(390, 90, 30)}, @@ -109,7 +108,6 @@ class TestOnceRecurrenceProducesDays: alloc_input = _build_input( today=date(2026, 9, 17), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(390, 90, 30)}, @@ -153,7 +151,6 @@ class TestWrappingWindowStraddlingToday: alloc_input = _build_input( today=today, horizon_end=date(2026, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(390, 90, 30)}, diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index 31b1964..968982e 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -101,6 +101,7 @@ def _wish( window_mode: str = "fixed", wish_id: UUID | None = None, name_message_key: str | None = None, + wants_dubbeldag: bool = False, ) -> LeaveWish: return LeaveWish( id=wish_id or uuid4(), @@ -121,6 +122,7 @@ def _wish( parent_mode=parent_mode, parent_personal_data_uuid=parent_uuid, window_mode=window_mode, + wants_dubbeldag=wants_dubbeldag, ) @@ -128,13 +130,13 @@ def _build_input( *, today: date, horizon_end: date, - planning_mode: str = "solo", children: list[ChildFacts], parents: list[ParentFacts], pools: dict[tuple[UUID, UUID], DayPool], vacation: dict[tuple[UUID, int], VacationBudget], wishes: list[LeaveWish], existing_planned: list | None = None, + all_existing_planned: list | None = None, extra_holiday_years: tuple[int, ...] = (), day_overrides: dict[date, bool] | None = None, age4_savable_cap: int | None = None, @@ -145,12 +147,11 @@ def _build_input( # `app.utils.tip_predicates.resolve_age4_savable_cap`. Computed here # by default so existing callers that don't care about the age-4 cap # split keep getting the historically-correct figure for their - # `planning_mode`/`parents` without having to pass it explicitly. - age4_savable_cap = resolve_age4_savable_cap(planning_mode, len(parents)) + # `parents` count without having to pass it explicitly. + age4_savable_cap = resolve_age4_savable_cap(len(parents)) return AllocationInput( today=today, horizon_end=horizon_end, - planning_mode=planning_mode, age4_savable_cap=age4_savable_cap, children=children, parents=parents, @@ -160,18 +161,28 @@ def _build_input( existing_planned=existing_planned or [], holidays=_holidays_for(*years), day_overrides=day_overrides or {}, + all_existing_planned=all_existing_planned, ) def _planned( - *, parent_uuid: UUID, child_id: UUID, on: date, day_type: str = "vacation" + *, + parent_uuid: UUID, + child_id: UUID, + on: date, + day_type: str = "vacation", + wish_id: UUID | None = None, ) -> PlannedLeaveDay: """A persisted `planned_leave_day` row — the "already applied, before this `propose()` run" building block for the §fix (double-booking on - flexible re-propose) test cases below. Only the fields - `AllocationInput.existing_planned` actually reads - (`parent_personal_data_uuid`, `child_id`, `date`, `day_type`) matter. + flexible re-propose; extended for §fix parent-reassignment, decision B) + test cases below. Only the fields `AllocationInput.existing_planned`/ + `all_existing_planned` actually read (`parent_personal_data_uuid`, + `child_id`, `date`, `day_type`, `wish_id`) matter. `wish_id` is what + `_Ledger._persisted_by_wish` now matches a flexible wish's own prior + placement by — pass the wish's `.id` whenever a test relies on that + reuse mechanism finding these rows. """ return PlannedLeaveDay( id=uuid4(), @@ -180,6 +191,7 @@ def _planned( parent_personal_data_uuid=parent_uuid, date=on, day_type=day_type, + wish_id=wish_id, ) @@ -209,7 +221,6 @@ class TestCase1SoloUnder12MonthsFallsBackToFP: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, @@ -247,7 +258,6 @@ class TestCase2CoupleSwap: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ @@ -291,7 +301,6 @@ class TestCase3MonthlySalaryVacationFirst: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, @@ -330,7 +339,6 @@ class TestCase4HourlySalaryFPFirst: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, @@ -365,7 +373,6 @@ class TestCase5ShortfallByPriority: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(30, 0, 60)}, @@ -401,7 +408,6 @@ class TestCase6YearWrapping: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, @@ -439,7 +445,6 @@ class TestCase7SeparatePoolsPerChild: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child_a, child_b], parents=[parent], pools={ @@ -474,7 +479,6 @@ class TestCase8DaysLostAtAge4Warning: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2029, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 0, 60)}, @@ -504,7 +508,6 @@ class TestCase9MovableEaster: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -529,7 +532,19 @@ class TestCase9MovableEaster: class TestCase10DubbeldagOutsideWindow: - def test_falls_back_to_swap_when_outside_15_months(self) -> None: + """§Decision A (co-leave, 2026-09-18 correction) REVERSES this case's old + + product behaviour: a `parent_mode='both'` wish outside the 15-month + dubbeldagar window (or, per the correction, simply never opting in via + `wants_dubbeldag`) no longer falls back to a sequential parent-swap. It + now co-leaves: BOTH parents off on every SAME date, funded independently + (PL to whichever parent has more FP left, vacation to the other) — + "both" means both parents home together, not "each parent gets half the + weeks". See PLANNING_ENGINE_SPEC.md-adjacent handoff notes and + `allocation_service.py`'s `_Ledger._fund_both` docstring. + """ + + def test_co_leave_places_both_parents_on_the_same_dates_when_outside_15_months(self) -> None: child = _child(birth_date=date(2024, 1, 1)) # 15mo cutoff: 2025-04-01 parent_a = _parent(name="A") parent_b = _parent(name="B") @@ -539,11 +554,11 @@ class TestCase10DubbeldagOutsideWindow: end=FEB_2027[1], # well after the 15-month window weeks=4, parent_mode="both", + # wants_dubbeldag defaults False -- opt-in only (Decision A). ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ @@ -566,6 +581,34 @@ class TestCase10DubbeldagOutsideWindow: parent_b.uuid, } + outcome = proposal.outcomes[0] + assert outcome.strategy == "co_leave" + assert outcome.requested_strategy == "co_leave" + assert outcome.strategy_degraded is False + assert outcome.shortfall_days == 0 + + # The headline product behaviour: every non-free day is BOTH parents + # on the SAME date (not parent_a taking the first half of the month + # and parent_b the second, which is what the old sequential-swap + # fallback would have produced), one funded from FP, the other from + # vacation. + non_free = [d for d in proposal.days if d.day_type != "free"] + by_date: dict[date, list] = {} + for d in non_free: + by_date.setdefault(d.date, []).append(d) + assert by_date, "expected at least one funded day" + for on_date, entries in by_date.items(): + parents_that_day = {e.parent_personal_data_uuid for e in entries} + assert parents_that_day == {parent_a.uuid, parent_b.uuid}, ( + f"{on_date}: expected both parents, got {parents_that_day}" + ) + # Co-leave's PL+vacation split: exactly one FP day + one vacation + # day per date -- ample pools here mean it never has to fall back + # further, so this holds for every date, not just "on average". + types_that_day = sorted(e.day_type for e in entries) + assert types_that_day[1] == "vacation", (on_date, types_that_day) + assert types_that_day[0] in ("sjukpenning", "lagsta"), (on_date, types_that_day) + # --------------------------------------------------------------------------- # #11 — re-run propose with identical input -> byte-identical output @@ -581,7 +624,6 @@ class TestCase11Determinism: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ @@ -615,7 +657,6 @@ class TestCase12Age4HardCap: alloc_input = _build_input( today=date(2027, 1, 15), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 0, 60)}, @@ -655,7 +696,6 @@ class TestCase12Age4HardCap: alloc_input = _build_input( today=date(2027, 1, 15), horizon_end=date(2027, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ @@ -705,7 +745,6 @@ class TestCase13PreschoolReserveRemedy: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), # single year -> reserve = 6*1 = 6 - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(15, 0, 60)}, @@ -789,7 +828,6 @@ class TestRegressionSoloVacationExhaustedFPAvailable: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2030, 12, 31), - planning_mode="solo", children=[child], parents=[parent], # Healthy FP pools — must be used as fallback when vacation runs out. @@ -847,7 +885,6 @@ class TestRegressionSoloVacationExhaustedFPAvailable: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, @@ -955,7 +992,6 @@ class TestRegressionNoShortfallWhileResourcesRemain: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, @@ -1002,7 +1038,6 @@ class TestRegressionNoShortfallWhileResourcesRemain: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], # Total pool: 30 sjukpenning. Feb needs ~20, Aug needs ~22. @@ -1048,7 +1083,6 @@ class TestRegressionGenuineShortfallStillReported: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], # Empty pools. @@ -1087,7 +1121,6 @@ class TestRegressionGenuineShortfallStillReported: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], # Total 25 days: barely enough for Feb (20), but not Aug (22). @@ -1132,7 +1165,6 @@ class TestRegressionTwoParentSwapUnaffected: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ @@ -1184,7 +1216,6 @@ class TestCase15FlexibleWishFindsEasterCluster: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1226,7 +1257,6 @@ class TestCase16FlexibleTieBreakByEarliestStart: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1275,7 +1305,6 @@ class TestFlexibleWishSizedByTargetDays: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1317,7 +1346,6 @@ class TestCase17TwoFlexibleWishesPriorityOrdering: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1370,7 +1398,6 @@ class TestCase18FixedWishAlwaysWinsOverFlexible: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1411,7 +1438,6 @@ class TestCase19FlexibleBoundShrunkByHorizonIsPartialNotUnplaced: # 7 days target_weeks needs. today=date(2027, 5, 20), horizon_end=date(2027, 5, 25), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1456,7 +1482,6 @@ class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1509,7 +1534,6 @@ class TestCase21FlexibleYearlyWishTracksMovingEaster: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2029, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(400, 90, 60)}, @@ -1554,7 +1578,6 @@ class TestCase22FlexibleDubbeldagOutsideWindowFallsBack: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ @@ -1605,7 +1628,6 @@ class TestCase23FlexibleWishDeterminism: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ @@ -1653,7 +1675,6 @@ class TestCase24WindowModeDefaultsToFixed: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1698,13 +1719,14 @@ class TestCase25FlexibleAlreadyFundedYearProposesNothingNew: # The exact 3 spendable days TestCase15 funds when this wish-year is # searched fresh -- simulating a prior propose -> apply cycle. existing = [ - _planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, d)) + _planned( + parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, d), wish_id=wish.id + ) for d in (23, 24, 25) ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1743,11 +1765,14 @@ class TestCase26FlexiblePartialFundingTopsUpRemainder: window_mode="flexible", ) # Only ONE of the winning window's 3 spendable days was ever applied. - existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))] + existing = [ + _planned( + parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23), wish_id=wish.id + ) + ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1787,11 +1812,14 @@ class TestCase27FlexibleDeterminismWithExistingPlannedDays: first_year=2027, window_mode="flexible", ) - existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))] + existing = [ + _planned( + parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23), wish_id=wish.id + ) + ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1820,10 +1848,6 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied: def test_prior_window_abandoned_when_a_fixed_wish_claims_part_of_it(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") - # Only 2027-03-23 was ever applied for "Vårlov" -- the other two - # spendable days of the Easter window (03-24, 03-25) were proposed - # last time but never applied. - existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))] # A brand-new fixed wish now claims 2027-03-25 -- one of the Easter # window's still-open days -- and, per §3.9/§3.11.2, always wins. blocker = _wish( @@ -1844,10 +1868,20 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied: first_year=2027, window_mode="flexible", ) + # Only 2027-03-23 was ever applied for "Vårlov" -- the other two + # spendable days of the Easter window (03-24, 03-25) were proposed + # last time but never applied. + existing = [ + _planned( + parent_uuid=parent.uuid, + child_id=child.id, + on=date(2027, 3, 23), + wish_id=flexible.id, + ) + ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -1919,7 +1953,6 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(5, 5, 60)}, @@ -1963,7 +1996,6 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(5, 5, 60)}, @@ -2025,11 +2057,15 @@ class TestCase23DubbeldagCountCapFallsBackToSwap: weeks=26, first_year=2023, parent_mode="both", + # §Decision A (2026-09-18 correction): dubbeldagar are opt-in -- + # must be explicitly requested, or `_fund_both` never attempts + # them at all and this cap-fallback scenario has nothing to + # fall back FROM. + wants_dubbeldag=True, ) alloc_input = _build_input( today=date(2023, 1, 1), horizon_end=date(2023, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ @@ -2069,3 +2105,318 @@ class TestCase23DubbeldagCountCapFallsBackToSwap: # The fallback only ever kicks in for dates after the cap was reached # (`used` is monotonically increasing within the window). assert min(leftover_parents_by_date) > max(dubbel_dates_a) + + +# --------------------------------------------------------------------------- +# §fix — reassigning a wish's parent must not move its dates (decision B, +# 2026-09-18 handoff: Höstlov reassigned from Amelie to Jacob jumped +# windows). `_reuse_already_funded_window` already existed for exactly this +# purpose but matched a wish's prior placement by (parent, child, date) -- +# reassigning the parent changes `_resolve_parents`'s output BEFORE that +# match runs, so it silently stopped finding the wish's own history and fell +# through to a fresh, occupancy-blind search. Fixed by matching on `wish_id` +# instead (`_Ledger._persisted_by_wish`), which finds the prior window +# regardless of which parent it was funded under. +# --------------------------------------------------------------------------- + + +class TestFixReassigningParentDoesNotMoveDates: + def test_reassigning_the_parent_leaves_start_and_end_unchanged(self) -> None: + """THE headline regression: same wish, same window fields, only + + `parent_personal_data_uuid` changes (Amelie -> Jacob) -- the + already-applied window (2027-04-05..04-11) must be reused, funded + now for the new parent, not re-searched from scratch. A fresh, + occupancy-blind search over this bound (2027-04-05..05-31) lands on + 2027-04-30..05-06 instead (cost 4 vs. the original week's cost 5 -- + strictly cheaper by calendar) -- that is exactly the bug this test + pins down (verified against a live pre-fix run: pytest failed with + `assert datetime.date(2027, 4, 30) == datetime.date(2027, 4, 5)`). + """ + child = _child(birth_date=date(2020, 1, 1)) + amelie = _parent(name="Amelie", salary_type="monthly") + jacob = _parent(name="Jacob", salary_type="monthly") + wish_id = uuid4() + + # Amelie's prior apply: the Mon-Fri workdays of 2027-04-05..04-11 + # (the weekend itself is never persisted -- free days are implicit, + # exactly like TestCase25/26's `existing` lists). + existing = [ + _planned( + parent_uuid=amelie.uuid, + child_id=child.id, + on=date(2027, 4, d), + wish_id=wish_id, + ) + for d in (5, 6, 7, 8, 9) + ] + + reassigned_wish = _wish( + wish_id=wish_id, + child_id=child.id, + name="Höstlov", + start=(4, 5), + end=(5, 31), + weeks=1, + first_year=2027, + window_mode="flexible", + parent_mode="specific", + parent_uuid=jacob.uuid, # <- the only thing that changed + ) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + children=[child], + parents=[amelie, jacob], + pools={ + (amelie.uuid, child.id): DayPool(90, 90, 60), + (jacob.uuid, child.id): DayPool(90, 90, 60), + }, + vacation={ + (amelie.uuid, 2027): VacationBudget(25, 0, 0), + (jacob.uuid, 2027): VacationBudget(25, 0, 0), + }, + wishes=[reassigned_wish], + existing_planned=existing, + ) + + proposal = Allocator().propose(alloc_input) + + outcome = proposal.outcomes[0] + assert outcome.start_date == date(2027, 4, 5) + assert outcome.end_date == date(2027, 4, 11) + assert outcome.shortfall_days == 0 + + # Funded now for the NEW parent, not the old one. + assert {d.parent_personal_data_uuid for d in proposal.days} == {jacob.uuid} + + def test_editing_the_actual_dates_still_moves_it(self) -> None: + """The flip side: when the user genuinely edits start/end (not just + + the parent), the wish's placement DOES move -- reuse must never + pin a wish to a window its current bound no longer contains. + """ + child = _child(birth_date=date(2020, 1, 1)) + parent = _parent(name="Amelie", salary_type="monthly") + wish_id = uuid4() + + existing = [ + _planned( + parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 4, d), wish_id=wish_id + ) + for d in (5, 6, 7, 8, 9) + ] + + # Same wish, same parent -- but the user dragged the period to + # September instead. + edited_wish = _wish( + wish_id=wish_id, + child_id=child.id, + name="Höstlov", + start=(9, 1), + end=(9, 30), + weeks=1, + first_year=2027, + window_mode="flexible", + parent_mode="specific", + parent_uuid=parent.uuid, + ) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, + vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, + wishes=[edited_wish], + existing_planned=existing, + ) + + proposal = Allocator().propose(alloc_input) + + outcome = proposal.outcomes[0] + assert outcome.start_date.month == 9 + assert outcome.end_date.month == 9 + assert (outcome.start_date, outcome.end_date) != (date(2027, 4, 5), date(2027, 4, 11)) + + +# --------------------------------------------------------------------------- +# §Decision A (co-leave, 2026-09-18 correction) — dubbeldagar are opt-in. +# --------------------------------------------------------------------------- + + +class TestDubbeldagOptIn: + def _build(self, *, wants_dubbeldag: bool): + child = _child(birth_date=date(2027, 1, 1)) # 15mo cutoff: 2028-04-01 + parent_a = _parent(name="A") + parent_b = _parent(name="B") + wish = _wish( + child_id=child.id, + start=FEB_2027[0], + end=FEB_2027[1], # well inside the 15-month window + weeks=4, + parent_mode="both", + wants_dubbeldag=wants_dubbeldag, + ) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + children=[child], + parents=[parent_a, parent_b], + pools={ + (parent_a.uuid, child.id): DayPool(100, 90, 60), + (parent_b.uuid, child.id): DayPool(100, 90, 60), + }, + vacation={ + (parent_a.uuid, 2027): VacationBudget(20, 0, 0), + (parent_b.uuid, 2027): VacationBudget(20, 0, 0), + }, + wishes=[wish], + ) + return Allocator().propose(alloc_input) + + def test_flag_unset_never_consumes_dubbeldagar_even_inside_the_window(self) -> None: + proposal = self._build(wants_dubbeldag=False) + + assert not any(d.is_dubbeldag for d in proposal.days) + outcome = proposal.outcomes[0] + assert outcome.strategy == "co_leave" + assert outcome.requested_strategy == "co_leave" + assert outcome.strategy_degraded is False + # Legally available (child inside the window, quota free, both + # parents hold FP) even though it wasn't requested -- the frontend + # uses this to decide whether to SHOW the opt-in checkbox. + assert outcome.dubbeldagar_legally_available is True + + def test_flag_set_consumes_dubbeldagar_inside_the_window(self) -> None: + proposal = self._build(wants_dubbeldag=True) + + assert any(d.is_dubbeldag for d in proposal.days) + outcome = proposal.outcomes[0] + assert outcome.strategy == "dubbeldag" + assert outcome.requested_strategy == "dubbeldag" + assert outcome.strategy_degraded is False + assert outcome.dubbeldagar_legally_available is True + + +# --------------------------------------------------------------------------- +# §Decision C — candidate-window selection must be budget-aware, not just +# calendar-cheapest. +# --------------------------------------------------------------------------- + + +class TestFlexibleCandidateSelectionIsBudgetAware: + def test_unfundable_cheapest_candidate_is_skipped_for_a_fundable_one(self) -> None: + """A single parent, single child. The wish's flexible bound + + (2027-12-01..2028-02-28) contains a strictly-cheaper-by-calendar + window over the Christmas/New Year red-day cluster, but that + window's spendable days fall in 2027, a year with ZERO vacation and + ZERO FP left -- completely unfundable. A plain (costlier) week + entirely in 2028, where the parent has ample vacation, is not + calendar-cheapest but IS fully payable. Budget-aware ranking must + prefer the payable window; the old calendar-only `_candidate_cost` + ranking would have picked the unfundable one and reported a + shortfall despite a perfectly fundable window existing right next + to it in the same bound. + + The very cheapest candidate overall (2027-12-31..2028-01-06) is + blocked by an unrelated pre-existing commitment on 2028-01-04 so + this test isolates the budget-vs-calendar-cost tradeoff rather than + that one coincidentally-fundable window (its 3 spendable days all + happen to fall in 2028 -- verified against a live run before writing + this test). + """ + child = _child(birth_date=date(2020, 1, 1)) + parent = _parent(salary_type="monthly") + wish = _wish( + child_id=child.id, + name="Jul", + start=(12, 1), + end=(1, 31), # wraps into 2028 + weeks=1, + first_year=2027, + window_mode="flexible", + ) + blocker = _planned( + parent_uuid=parent.uuid, child_id=child.id, on=date(2028, 1, 4), day_type="vacation" + ) + alloc_input = _build_input( + today=date(2027, 11, 1), + horizon_end=date(2028, 12, 31), + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(0, 0, 60)}, + vacation={ + (parent.uuid, 2027): VacationBudget(0, 0, 0), + (parent.uuid, 2028): VacationBudget(30, 0, 0), + }, + wishes=[wish], + existing_planned=[blocker], + extra_holiday_years=(2028,), + ) + + proposal = Allocator().propose(alloc_input) + + outcome = proposal.outcomes[0] + assert outcome.shortfall_days == 0 + assert outcome.start_date.year == 2028 + assert outcome.end_date.year == 2028 + + +# --------------------------------------------------------------------------- +# §Decision C — `strategy` must report what actually happened, never what +# was merely requested. +# --------------------------------------------------------------------------- + + +class TestStrategyReportsDegradation: + def test_both_mode_falling_back_to_swap_is_reported_not_hidden(self) -> None: + """Parent A has FP but no partner-side vacation counterpart to pair + + with; Parent B has neither FP nor vacation at all, so co-leave can + never pay Parent B's half of any date. Every date genuinely can't + be co-funded -> the whole window degrades to a sequential swap + (handed entirely to Parent A, who can afford it alone). `strategy` + must say 'swap', not silently keep claiming 'co_leave'. + """ + child = _child(birth_date=date(2020, 1, 1)) + parent_a = _parent(name="A", salary_type="monthly") + parent_b = _parent(name="B", salary_type="monthly") + wish = _wish( + child_id=child.id, + start=FEB_2027[0], + end=FEB_2027[1], + weeks=4, + parent_mode="both", + ) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + children=[child], + parents=[parent_a, parent_b], + pools={ + (parent_a.uuid, child.id): DayPool(100, 0, 60), + (parent_b.uuid, child.id): DayPool(0, 0, 60), + }, + vacation={ + (parent_a.uuid, 2027): VacationBudget(30, 0, 0), + (parent_b.uuid, 2027): VacationBudget(0, 0, 0), + }, + wishes=[wish], + ) + + proposal = Allocator().propose(alloc_input) + + outcome = proposal.outcomes[0] + assert outcome.requested_strategy == "co_leave" + assert outcome.strategy == "swap" + assert outcome.strategy_degraded is True + assert outcome.shortfall_days == 0 # parent A alone covers every spendable day + + non_free_b = [ + d + for d in proposal.days + if d.parent_personal_data_uuid == parent_b.uuid and d.day_type != "free" + ] + assert non_free_b == [], "parent B cannot fund anything -- must get nothing, not a lie" diff --git a/backend/tests/unit/test_services/test_allocation_service_branches.py b/backend/tests/unit/test_services/test_allocation_service_branches.py index 3a998c5..efad54e 100644 --- a/backend/tests/unit/test_services/test_allocation_service_branches.py +++ b/backend/tests/unit/test_services/test_allocation_service_branches.py @@ -49,7 +49,6 @@ class TestParentModeSpecific: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ @@ -75,7 +74,6 @@ class TestFamilyWideWish: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[], parents=[parent], pools={}, @@ -94,7 +92,6 @@ class TestFamilyWideWish: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[], parents=[parent], pools={}, @@ -117,7 +114,6 @@ class TestTier3Fallbacks: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 0, 60)}, @@ -139,7 +135,6 @@ class TestTier3Fallbacks: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(2, 0, 60)}, @@ -167,7 +162,6 @@ class TestTier3Fallbacks: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -192,7 +186,6 @@ class TestTier3Fallbacks: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(0, 0, 60)}, @@ -208,7 +201,6 @@ class TestTier3Fallbacks: alloc_input_empty = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(0, 0, 60)}, @@ -226,7 +218,6 @@ class TestTier3Fallbacks: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 0, 60)}, @@ -248,7 +239,6 @@ class TestTier3Fallbacks: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(0, 0, 60)}, @@ -276,11 +266,14 @@ class TestDubbeldagSuccess: end=(2, 5), # Mon 1 Feb 2027 -> Fri 5 Feb 2027, well within 15mo weeks=1, parent_mode="both", + # §Decision A (2026-09-18 correction): dubbeldagar are opt-in -- + # without this flag `_fund_both` would co-leave (PL+vacation) + # instead, never touching the dubbeldagar quota at all. + wants_dubbeldag=True, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ @@ -315,7 +308,6 @@ class TestWarnings: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -334,7 +326,6 @@ class TestWarnings: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[], parents=[parent], pools={}, @@ -366,7 +357,6 @@ class TestRemedies: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(5, 0, 60)}, @@ -396,7 +386,6 @@ class TestRemedies: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(5, 0, 60)}, @@ -428,7 +417,6 @@ class TestRemedies: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(5, 0, 60)}, @@ -456,7 +444,6 @@ class TestRemedies: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(5, 0, 60)}, @@ -507,7 +494,6 @@ class TestRemedies: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(5, 0, 60)}, @@ -559,7 +545,6 @@ class TestDayOverrides: base_kwargs = dict( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, @@ -592,7 +577,6 @@ class TestDayOverrides: base_kwargs = dict( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, @@ -646,7 +630,6 @@ def _big_surplus_input(*, min_vacation_days_per_year: int = 10): alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2030, 12, 31), - planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(390, 90, 60)}, # 480 total, untouched @@ -713,7 +696,6 @@ class TestSurplusAdvice: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_mode="solo", children=[child], parents=[parent], # Pool already at/under the age-4 ceiling -> nothing "expiring". diff --git a/backend/tests/unit/test_services/test_allocation_swap_fairness.py b/backend/tests/unit/test_services/test_allocation_swap_fairness.py index ca3e50d..962fa26 100644 --- a/backend/tests/unit/test_services/test_allocation_swap_fairness.py +++ b/backend/tests/unit/test_services/test_allocation_swap_fairness.py @@ -87,7 +87,6 @@ def _jullov_wrap_scenario(*, parent_a_starved: bool): alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), - planning_mode="couple", children=[child], parents=[parent_a, parent_b], pools={ diff --git a/backend/tests/unit/test_services/test_analytics_service.py b/backend/tests/unit/test_services/test_analytics_service.py index 0a812f7..234c487 100644 --- a/backend/tests/unit/test_services/test_analytics_service.py +++ b/backend/tests/unit/test_services/test_analytics_service.py @@ -129,11 +129,14 @@ class TestAge4Projection: ) proj = await service.get_days_left_at_age_4(pd_uuid, child.id) - assert proj.total_days == 240 # default entitlement + # `seeded_ids`'s workspace has exactly one member -> a sole parent's + # default entitlement is the FULL pool (480), not a partnered + # parent's half-share (240) -- see `default_tier_pool_share`. + assert proj.total_days == 480 # default entitlement assert proj.days_used == 0 - assert proj.projected_left_at_b4 == 240 + assert proj.projected_left_at_b4 == 480 assert proj.savable_after_b4 == 96 - assert proj.expiring_at_b4 == 144 # 240 - 96 + assert proj.expiring_at_b4 == 384 # 480 - 96 async def test_leap_year_birthday( self, @@ -355,9 +358,7 @@ class TestCoupleWorkspacePerParentCap: from app.entities.workspace import Workspace now = datetime.now() - ws = await workspace_repo.create( - Workspace(id=uuid4(), name="Familj", planning_mode="couple", created_at=now) - ) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent_a = await personal_data_repo.create( PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now) ) @@ -394,7 +395,7 @@ class TestCoupleWorkspacePerParentCap: assert combined_savable == 96 # never 192 — this is the headline regression assert overview.combined_expiring_at_b4 == 384 # (240-48) * 2 - async def test_couple_mode_with_single_parent_falls_back_to_96( + async def test_single_member_workspace_falls_back_to_96( self, child_repo: SQLiteChildRepository, parental_leave_repo: SQLiteParentalLeaveRepository, @@ -402,18 +403,16 @@ class TestCoupleWorkspacePerParentCap: personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, ) -> None: - """Safety belt: `planning_mode == 'couple'` with only one member so + """Safety belt: a workspace with only one member so far (partner not - far (partner not added yet) must still use the per-child (96) - figure — there is no second parent to divide it with. + added yet) must still use the per-child (96) figure — there is no + second parent to divide it with. """ from app.entities.personal_data import PersonalData from app.entities.workspace import Workspace now = datetime.now() - ws = await workspace_repo.create( - Workspace(id=uuid4(), name="Familj", planning_mode="couple", created_at=now) - ) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now) ) @@ -432,6 +431,39 @@ class TestCoupleWorkspacePerParentCap: assert proj.expiring_at_b4 == 144 # 240 - 96 +class TestSingleParentWorkspaceCap: + """The age-4 cap is purely a function of parent count (no separate + + "planning mode" flag exists): a workspace with a single member gets the + full per-child figure (96); `TestCoupleWorkspacePerParentCap` above + covers the two-parent 48/48 split. + """ + + async def test_genuine_single_parent_workspace_gets_96( + self, + child_repo: SQLiteChildRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, + seeded_ids: SeededIds, + ) -> None: + ws = seeded_ids.workspace_id + pd_uuid = seeded_ids.personal_data_uuid + birth = date.today() + relativedelta(years=2) + child = _make_child(ws, birth) + await child_repo.create(child) + fetch = _make_fetch(pd_uuid, ws, child.id, total=240, used=0) + await parental_leave_repo.create(fetch) + + service = await _make_service( + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + ) + proj = await service.get_days_left_at_age_4(pd_uuid, child.id) + assert proj.savable_cap_at_b4 == 96 + assert proj.savable_after_b4 == 96 + + class TestAgreesWithPlanProposalForTheSameCommittedState: """Bug A regression: the Dashboard (`AnalyticsService`) and the Plan tab's propose-time projection (`allocation_service._Ledger.build_age4_projections`, @@ -439,13 +471,14 @@ class TestAgreesWithPlanProposalForTheSameCommittedState: `expiring_at_b4` for a parent with no FK snapshot yet and no leave wishes (so the proposal adds nothing new) — i.e. the *committed* state. - Before the fix, `AllocationInputLoader`'s no-snapshot fallback assumed a - single parent was entitled to the WHOLE child's combined tier totals - (`rules.sjukpenning_days` + `rules.lagsta_niva_days` == 390 + 90 == 480) - instead of that parent's fair half-share (195 + 45 == 240, matching - `AnalyticsService`'s own fallback, `rules.days_per_parent`). That made the - Plan tab report roughly double what the Dashboard showed for the exact - same (empty) committed plan — this test pins the two together. + Both surfaces derive their no-snapshot default through the same shared + `app.utils.tip_predicates.default_tier_pool_share(rules, parent_count)` + helper, so they can never disagree again: a genuinely SOLE parent (this + workspace has exactly one member, "Sam") is entitled to the WHOLE + child's combined tier totals (`rules.sjukpenning_days` + + `rules.lagsta_niva_days` == 390 + 90 == 480) since there's no partner to + split the pool with; a partnered parent would instead get their fair + half-share (195 + 45 == 240). This test pins the two surfaces together. """ async def test_no_snapshot_no_wishes_matches_dashboard( @@ -463,9 +496,7 @@ class TestAgreesWithPlanProposalForTheSameCommittedState: from app.services.allocation_service import AllocationInputLoader, Allocator now = datetime.now() - ws = await workspace_repo.create( - Workspace(id=uuid4(), name="Familj", planning_mode="couple", created_at=now) - ) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), @@ -512,5 +543,5 @@ class TestAgreesWithPlanProposalForTheSameCommittedState: assert dashboard_proj.expiring_at_b4 == plan_proj.expiring_at_b4 # Pin the actual figures too, so a future regression of either # fallback (240 vs. 480) fails loudly rather than merely disagreeing. - assert dashboard_proj.projected_left_at_b4 == 240 - assert dashboard_proj.expiring_at_b4 == 144 + assert dashboard_proj.projected_left_at_b4 == 480 + assert dashboard_proj.expiring_at_b4 == 384 diff --git a/backend/tests/unit/test_services/test_fetch_data_service.py b/backend/tests/unit/test_services/test_fetch_data_service.py index a5e5c6f..924cf6e 100644 --- a/backend/tests/unit/test_services/test_fetch_data_service.py +++ b/backend/tests/unit/test_services/test_fetch_data_service.py @@ -1,41 +1,41 @@ """Tests for `ManualFetchService` (backend/app/services/fetch_data_service.py). This is the manual-entry path a parent's Försäkringskassan balances enter the -app through — now the primary onboarding action. `git grep` found zero -existing coverage for this service or its router before this file. +app through — now the primary onboarding action. The service is a thin +wrapper over the shared `app.services.leave_snapshot.build_snapshot` +derivation (also used by the FK scraper), plus the "default `total_days` off +the workspace's parent count" behaviour that is unique to manual entry (a +scraped snapshot always carries FK's own total). -`test_fk_scraper.py::test_reserved_days_derived` already proves the -`reserved_days_left <= reserved_days_total` domain rule holds for the -scraper-side derivation; this file proves the equivalent manual-entry -boundary — the `ManualFetchService.save_manual_entry` validation itself — -which was previously untested. +`test_leave_snapshot.py` covers `build_snapshot`'s own derivation/validation +exhaustively; this file only proves the wrapper forwards correctly and gets +the parent-count default right, plus basic persistence round-trips. """ +from datetime import datetime +from unittest.mock import AsyncMock from uuid import uuid4 import pytest +from app.entities.personal_data import PersonalData from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository -from app.services.fetch_data_service import ManualFetchService +from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository +from app.services.fetch_data_service import ManualFetchService, persist_partner_deduction from app.utils.errors import ValidationError from tests.conftest import SeededIds def _valid_kwargs(seeded_ids: SeededIds, **overrides: object) -> dict: - base = dict( + base: dict[str, object] = dict( personal_data_uuid=seeded_ids.personal_data_uuid, workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, total_days=480, - days_used=100, - reserved_days_total=90, - reserved_days_left=90, - transferable_days_left=150, + sjukpenning_left=310, + lagsta_niva_left=70, dubbeldagar_left=60, - sjukpenning_total=390, - sjukpenning_used=80, - lagsta_niva_total=90, - lagsta_niva_used=20, + reserved_days_left=90, ) base.update(overrides) return base @@ -44,39 +44,50 @@ def _valid_kwargs(seeded_ids: SeededIds, **overrides: object) -> dict: @pytest.fixture def manual_fetch_service( parental_leave_repo: SQLiteParentalLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, ) -> ManualFetchService: - return ManualFetchService(parental_leave_repo) + return ManualFetchService(parental_leave_repo, personal_data_repo) class TestSaveManualEntryValidation: - async def test_days_used_exceeding_total_days_is_rejected( + async def test_sjukpenning_left_exceeding_total_is_rejected( self, manual_fetch_service: ManualFetchService, seeded_ids: SeededIds, ) -> None: - kwargs = _valid_kwargs(seeded_ids, total_days=100, days_used=101) - with pytest.raises(ValidationError, match="days_used cannot exceed total_days"): + kwargs = _valid_kwargs( + seeded_ids, + total_days=240, + sjukpenning_left=196, + lagsta_niva_left=0, + reserved_days_left=0, + ) + with pytest.raises(ValidationError, match="sjukpenning_left"): await manual_fetch_service.save_manual_entry(**kwargs) - async def test_days_used_equal_to_total_days_is_accepted( + async def test_sjukpenning_left_equal_to_total_is_accepted( self, manual_fetch_service: ManualFetchService, seeded_ids: SeededIds, ) -> None: - """The boundary itself (0 days left) is valid, not an off-by-one reject.""" - kwargs = _valid_kwargs(seeded_ids, total_days=100, days_used=100) + """The boundary itself (that tier fully unused) is valid, not an off-by-one reject.""" + kwargs = _valid_kwargs( + seeded_ids, + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=45, + reserved_days_left=90, + ) fetch = await manual_fetch_service.save_manual_entry(**kwargs) - assert fetch.days_left == 0 + assert fetch.days_left == 240 async def test_reserved_days_left_exceeding_total_is_rejected( self, manual_fetch_service: ManualFetchService, seeded_ids: SeededIds, ) -> None: - kwargs = _valid_kwargs(seeded_ids, reserved_days_total=90, reserved_days_left=91) - with pytest.raises( - ValidationError, match="reserved_days_left cannot exceed reserved_days_total" - ): + kwargs = _valid_kwargs(seeded_ids, reserved_days_left=91) + with pytest.raises(ValidationError, match="reserved_days_left"): await manual_fetch_service.save_manual_entry(**kwargs) async def test_reserved_days_left_equal_to_total_is_accepted( @@ -84,7 +95,7 @@ class TestSaveManualEntryValidation: manual_fetch_service: ManualFetchService, seeded_ids: SeededIds, ) -> None: - kwargs = _valid_kwargs(seeded_ids, reserved_days_total=90, reserved_days_left=90) + kwargs = _valid_kwargs(seeded_ids, reserved_days_left=90) fetch = await manual_fetch_service.save_manual_entry(**kwargs) assert fetch.reserved_days_left == 90 @@ -96,11 +107,8 @@ class TestSaveManualEntryValidation: kwargs = _valid_kwargs( seeded_ids, total_days=480, - days_used=100, - sjukpenning_total=390, - sjukpenning_used=80, - lagsta_niva_total=90, - lagsta_niva_used=20, + sjukpenning_left=310, + lagsta_niva_left=70, ) fetch = await manual_fetch_service.save_manual_entry(**kwargs) @@ -112,6 +120,68 @@ class TestSaveManualEntryValidation: assert fetch.created_at is not None assert fetch.updated_at is not None + async def test_deduced_source_round_trips( + self, + manual_fetch_service: ManualFetchService, + seeded_ids: SeededIds, + ) -> None: + kwargs = _valid_kwargs(seeded_ids, source="deduced") + fetch = await manual_fetch_service.save_manual_entry(**kwargs) + assert fetch.source == "deduced" + + +class TestSaveManualEntryTotalDaysDefault: + """`total_days` defaults to `rules.total_days_per_child // parent_count` + when omitted — the whole point of Task 5/§"default pool follows parent + count" for manual entry. + """ + + async def test_omitted_total_days_defaults_to_full_pool_for_a_sole_parent( + self, + manual_fetch_service: ManualFetchService, + seeded_ids: SeededIds, + ) -> None: + """`seeded_ids`'s workspace has exactly one member.""" + kwargs = _valid_kwargs(seeded_ids, sjukpenning_left=390, lagsta_niva_left=90) + del kwargs["total_days"] + fetch = await manual_fetch_service.save_manual_entry(**kwargs) + assert fetch.total_days == 480 + + async def test_omitted_total_days_defaults_to_half_pool_with_a_partner( + self, + manual_fetch_service: ManualFetchService, + personal_data_repo: SQLitePersonalDataRepository, + seeded_ids: SeededIds, + ) -> None: + await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=seeded_ids.workspace_id, + name="Partner", + created_at=datetime.now(), + ) + ) + kwargs = _valid_kwargs(seeded_ids, sjukpenning_left=195, lagsta_niva_left=45) + del kwargs["total_days"] + fetch = await manual_fetch_service.save_manual_entry(**kwargs) + assert fetch.total_days == 240 + + async def test_omitted_total_days_without_a_personal_data_repo_raises( + self, + parental_leave_repo: SQLiteParentalLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + """A `ManualFetchService` built without a `personal_data_repo` (e.g. a + direct-construction call site that always passes `total_days` + explicitly) can't resolve the default and must fail loudly rather + than silently guessing. + """ + service = ManualFetchService(parental_leave_repo) + kwargs = _valid_kwargs(seeded_ids) + del kwargs["total_days"] + with pytest.raises(ValueError, match="total_days"): + await service.save_manual_entry(**kwargs) + class TestSaveManualEntryPersistence: async def test_snapshot_persists_and_is_readable_via_get_latest( @@ -143,16 +213,22 @@ class TestSaveManualEntryPersistence: manual_fetch_service: ManualFetchService, seeded_ids: SeededIds, ) -> None: - await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=50)) - await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=60)) - await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=70)) + await manual_fetch_service.save_manual_entry( + **_valid_kwargs(seeded_ids, dubbeldagar_left=50) + ) + await manual_fetch_service.save_manual_entry( + **_valid_kwargs(seeded_ids, dubbeldagar_left=40) + ) + await manual_fetch_service.save_manual_entry( + **_valid_kwargs(seeded_ids, dubbeldagar_left=30) + ) history = await manual_fetch_service.list_history( seeded_ids.personal_data_uuid, seeded_ids.child_id ) assert len(history) == 3 - assert [h.days_used for h in history] == [70, 60, 50] + assert [h.dubbeldagar_left for h in history] == [30, 40, 50] async def test_list_history_with_no_snapshots_returns_empty_list( self, @@ -161,3 +237,133 @@ class TestSaveManualEntryPersistence: ) -> None: history = await manual_fetch_service.list_history(uuid4(), uuid4()) assert history == [] + + +class TestPersistPartnerDeduction: + """`persist_partner_deduction` — called by the FK scraper router's `_persist` + after `_deduce_partner` produces a `PartnerDeduction` for the family panel. + """ + + async def test_creates_a_deduced_snapshot_for_the_sole_other_member( + self, + parental_leave_repo: SQLiteParentalLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, + seeded_ids: SeededIds, + ) -> None: + partner = await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=seeded_ids.workspace_id, + name="Partner", + created_at=datetime.now(), + ) + ) + + await persist_partner_deduction( + personal_data_repo=personal_data_repo, + parental_leave_repo=parental_leave_repo, + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + caller_personal_data_uuid=seeded_ids.personal_data_uuid, + total_days=240, + sjukpenning_left=130, + lagsta_niva_left=20, + dubbeldagar_left=20, + ) + + snapshot = await parental_leave_repo.get_latest_by_parent(partner.uuid, seeded_ids.child_id) + assert snapshot is not None + assert snapshot.source == "deduced" + assert snapshot.days_left == 150 + assert snapshot.tier_data.sjukpenning_left == 130 + assert snapshot.tier_data.lagsta_niva_left == 20 + + async def test_sole_member_workspace_persists_nothing( + self, + parental_leave_repo: SQLiteParentalLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, + seeded_ids: SeededIds, + ) -> None: + """`seeded_ids`'s workspace has exactly one member (the caller) — there + is no "other member" to attribute a deduced snapshot to.""" + await persist_partner_deduction( + personal_data_repo=personal_data_repo, + parental_leave_repo=parental_leave_repo, + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + caller_personal_data_uuid=seeded_ids.personal_data_uuid, + total_days=240, + sjukpenning_left=130, + lagsta_niva_left=20, + dubbeldagar_left=20, + ) + + history = await parental_leave_repo.list_history( + seeded_ids.personal_data_uuid, seeded_ids.child_id + ) + assert history == [] + + async def test_more_than_one_other_member_persists_nothing( + self, + parental_leave_repo: SQLiteParentalLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, + seeded_ids: SeededIds, + ) -> None: + """Ambiguous "which member is the partner" — never guess.""" + for name in ("Partner A", "Partner B"): + await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=seeded_ids.workspace_id, + name=name, + created_at=datetime.now(), + ) + ) + + await persist_partner_deduction( + personal_data_repo=personal_data_repo, + parental_leave_repo=parental_leave_repo, + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + caller_personal_data_uuid=seeded_ids.personal_data_uuid, + total_days=240, + sjukpenning_left=130, + lagsta_niva_left=20, + dubbeldagar_left=20, + ) + + history = await parental_leave_repo.list_history( + seeded_ids.personal_data_uuid, seeded_ids.child_id + ) + assert history == [] + + async def test_repo_failure_is_swallowed_not_raised( + self, + personal_data_repo: SQLitePersonalDataRepository, + seeded_ids: SeededIds, + ) -> None: + """The caller's own snapshot is already saved by the time this runs — + a DB error persisting the partner's snapshot must never fail the + scraper session.""" + await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=seeded_ids.workspace_id, + name="Partner", + created_at=datetime.now(), + ) + ) + failing_repo = AsyncMock() + failing_repo.create.side_effect = RuntimeError("DB unavailable") + + await persist_partner_deduction( + personal_data_repo=personal_data_repo, + parental_leave_repo=failing_repo, + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + caller_personal_data_uuid=seeded_ids.personal_data_uuid, + total_days=240, + sjukpenning_left=130, + lagsta_niva_left=20, + dubbeldagar_left=20, + ) # must not raise diff --git a/backend/tests/unit/test_services/test_leave_snapshot.py b/backend/tests/unit/test_services/test_leave_snapshot.py new file mode 100644 index 0000000..d593278 --- /dev/null +++ b/backend/tests/unit/test_services/test_leave_snapshot.py @@ -0,0 +1,285 @@ +"""Tests for `app.services.leave_snapshot.build_snapshot`. + +This is the ONE shared derivation both the FK scraper +(`app.scrapers.fk_scraper._extract_parental_leave`) and manual entry +(`app.services.fetch_data_service.ManualFetchService.save_manual_entry`) go +through — see its module docstring. `test_fk_scraper.py` proves the scraper +still gets identical results after the move; this file is the exhaustive, +direct unit-level coverage of the derivation and validation itself. +""" + +from uuid import uuid4 + +import pytest + +from app.services.leave_snapshot import build_snapshot +from app.utils.errors import ValidationError + + +def _ids() -> dict: + return dict(personal_data_uuid=uuid4(), workspace_id=uuid4(), child_id=uuid4()) + + +class TestTotalsByParentCount: + def test_240_total_splits_into_195_and_45(self) -> None: + """Two-parent share: 195 sjukpenning + 45 lägstanivå = 240.""" + fetch = build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=45, + dubbeldagar_left=60, + reserved_days_left=90, + ) + assert fetch.tier_data.sjukpenning_total == 195 + assert fetch.tier_data.lagsta_niva_total == 45 + assert fetch.total_days == 240 + assert fetch.days_left == 240 + assert fetch.days_used == 0 + + def test_480_total_splits_into_390_and_90(self) -> None: + """Sole-parent share: the full 390 + 90 = 480.""" + fetch = build_snapshot( + **_ids(), + source="manual", + total_days=480, + sjukpenning_left=390, + lagsta_niva_left=90, + dubbeldagar_left=60, + reserved_days_left=90, + ) + assert fetch.tier_data.sjukpenning_total == 390 + assert fetch.tier_data.lagsta_niva_total == 90 + assert fetch.total_days == 480 + assert fetch.days_left == 480 + + def test_days_used_and_tier_used_are_derived(self) -> None: + fetch = build_snapshot( + **_ids(), + source="manual", + total_days=480, + sjukpenning_left=310, + lagsta_niva_left=70, + dubbeldagar_left=60, + reserved_days_left=90, + ) + assert fetch.days_left == 380 + assert fetch.days_used == 100 + assert fetch.tier_data.sjukpenning_used == 80 + assert fetch.tier_data.lagsta_niva_used == 20 + + +class TestReservedTransferableSplit: + def test_explicit_reserved_days_left_is_used_as_given(self) -> None: + fetch = build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=150, + lagsta_niva_left=20, + dubbeldagar_left=60, + reserved_days_left=60, + ) + assert fetch.reserved_days_total == 90 + assert fetch.reserved_days_left == 60 + # days_left = 170; transferable_days_left = days_left - reserved_days_left + assert fetch.transferable_days_left == 170 - 60 + + def test_reserved_days_left_derived_transferable_first(self) -> None: + """240 total, 100 days_left, none given explicitly -> the classic + "transferable spent first" derivation: reserved untouched until the + 150 transferable days are gone. + """ + fetch = build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=60, + lagsta_niva_left=40, + dubbeldagar_left=60, + ) + assert fetch.days_left == 100 + assert fetch.reserved_days_total == 90 + assert fetch.reserved_days_left == 90 + assert fetch.transferable_days_left == 10 + + def test_reserved_days_left_derived_once_transferable_is_exhausted(self) -> None: + """240 total, only 30 days_left -> transferable (150) is long gone, + so all 30 remaining days must be reserved ones.""" + fetch = build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=30, + lagsta_niva_left=0, + dubbeldagar_left=60, + ) + assert fetch.days_left == 30 + assert fetch.reserved_days_left == 30 + assert fetch.transferable_days_left == 0 + + +class TestNegativeAndOverTotalGuards: + def test_negative_sjukpenning_left_raises(self) -> None: + with pytest.raises(ValidationError, match="negative"): + build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=-1, + lagsta_niva_left=45, + dubbeldagar_left=60, + reserved_days_left=90, + ) + + def test_negative_lagsta_niva_left_raises(self) -> None: + with pytest.raises(ValidationError, match="negative"): + build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=-1, + dubbeldagar_left=60, + reserved_days_left=90, + ) + + def test_sjukpenning_left_exceeding_total_raises(self) -> None: + with pytest.raises(ValidationError, match="sjukpenning_left"): + build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=196, + lagsta_niva_left=0, + dubbeldagar_left=60, + reserved_days_left=0, + ) + + def test_lagsta_niva_left_exceeding_total_raises(self) -> None: + with pytest.raises(ValidationError, match="lagsta_niva_left"): + build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=0, + lagsta_niva_left=46, + dubbeldagar_left=60, + reserved_days_left=0, + ) + + def test_negative_explicit_reserved_days_left_raises(self) -> None: + with pytest.raises(ValidationError, match="negative"): + build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=45, + dubbeldagar_left=60, + reserved_days_left=-1, + ) + + def test_reserved_days_left_exceeding_total_raises(self) -> None: + with pytest.raises(ValidationError, match="reserved_days_left"): + build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=45, + dubbeldagar_left=60, + reserved_days_left=91, + ) + + def test_reserved_days_left_equal_to_total_is_accepted(self) -> None: + fetch = build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=45, + dubbeldagar_left=60, + reserved_days_left=90, + ) + assert fetch.reserved_days_left == 90 + + def test_explicit_reserved_days_left_exceeding_days_left_raises(self) -> None: + """reserved_days_left (90) > days_left (10) -> transferable_days_left + would go negative; that must be rejected, not silently persisted.""" + with pytest.raises(ValidationError, match="negative"): + build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=10, + lagsta_niva_left=0, + dubbeldagar_left=60, + reserved_days_left=90, + ) + + +class TestDubbeldagarBound: + def test_negative_dubbeldagar_left_raises(self) -> None: + with pytest.raises(ValidationError, match="negative"): + build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=45, + dubbeldagar_left=-1, + reserved_days_left=90, + ) + + def test_dubbeldagar_left_exceeding_max_raises(self) -> None: + with pytest.raises(ValidationError, match="dubbeldagar_left"): + build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=45, + dubbeldagar_left=61, + reserved_days_left=90, + ) + + def test_dubbeldagar_left_at_max_is_accepted(self) -> None: + fetch = build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=45, + dubbeldagar_left=60, + reserved_days_left=90, + ) + assert fetch.dubbeldagar_left == 60 + + +class TestSource: + @pytest.mark.parametrize("source", ["scraped", "manual", "deduced"]) + def test_valid_sources_are_accepted(self, source: str) -> None: + fetch = build_snapshot( + **_ids(), + source=source, + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=45, + dubbeldagar_left=60, + reserved_days_left=90, + ) + assert fetch.source == source + + def test_invalid_source_raises(self) -> None: + with pytest.raises(ValidationError, match="source"): + build_snapshot( + **_ids(), + source="guessed", + total_days=240, + sjukpenning_left=195, + lagsta_niva_left=45, + dubbeldagar_left=60, + reserved_days_left=90, + ) diff --git a/backend/tests/unit/test_services/test_tips_service.py b/backend/tests/unit/test_services/test_tips_service.py index 6724212..36b37d5 100644 --- a/backend/tests/unit/test_services/test_tips_service.py +++ b/backend/tests/unit/test_services/test_tips_service.py @@ -160,7 +160,6 @@ def _make_ctx( vacation_by_parent: dict | None = None, leave_wishes: list | None = None, today: date | None = None, - planning_mode: str = "solo", ) -> TipsContext: ws = workspace_id or uuid4() return TipsContext( @@ -172,7 +171,6 @@ def _make_ctx( vacation_by_parent=vacation_by_parent or {}, leave_wishes=leave_wishes or [], today=today or date.today(), - workspace_planning_mode=planning_mode, ) @@ -322,13 +320,13 @@ class TestPreserveForAge4: assert tip is not None assert tip.data["expiring_days"] == 74 - async def test_couple_workspace_with_two_parents_uses_per_parent_cap(self): - """A two-parent `couple` workspace caps each parent at 48, not 96 — + async def test_two_parent_workspace_uses_per_parent_cap(self): + """A two-parent workspace caps each parent at 48, not 96 — the - the headline bug this task fixes. Same inputs as + headline bug this task fixes. Same inputs as `test_fires_when_days_will_expire` (total=240, used=20 -> projected - 220) but with a second parent and `planning_mode='couple'`: - expiring should be 220-48=172, not 220-96=124. + 220) but with a second parent: expiring should be 220-48=172, not + 220-96=124. """ ws = uuid4() child = _make_child(date.today() - relativedelta(years=1)) @@ -340,7 +338,6 @@ class TestPreserveForAge4: children=[child], parents=[p1, p2], leave_by_parent={p1.uuid: leave1}, - planning_mode="couple", ) tip = await _rule_preserve_for_age_4(ctx) assert tip is not None @@ -374,7 +371,6 @@ class TestAge4PoolingLoss: children=[child], parents=[a, b], leave_by_parent={a.uuid: leave_a, b.uuid: leave_b}, - planning_mode="couple", ) tip = await _rule_age4_pooling_loss(ctx) assert tip is not None @@ -405,7 +401,6 @@ class TestAge4PoolingLoss: children=[child], parents=[a, b], leave_by_parent={a.uuid: leave_a, b.uuid: leave_b}, - planning_mode="couple", ) tip = await _rule_age4_pooling_loss(ctx) assert tip is not None @@ -426,7 +421,6 @@ class TestAge4PoolingLoss: children=[child], parents=[a, b], leave_by_parent={a.uuid: leave_a, b.uuid: None}, - planning_mode="couple", ) tip = await _rule_age4_pooling_loss(ctx) assert tip is not None @@ -448,7 +442,6 @@ class TestAge4PoolingLoss: children=[child], parents=[a, b], leave_by_parent={a.uuid: leave_a, b.uuid: leave_b}, - planning_mode="couple", ) tip = await _rule_age4_pooling_loss(ctx) assert tip is None @@ -465,24 +458,6 @@ class TestAge4PoolingLoss: children=[child], parents=[a, b], leave_by_parent={a.uuid: leave_a, b.uuid: leave_b}, - planning_mode="couple", - ) - tip = await _rule_age4_pooling_loss(ctx) - assert tip is None - - async def test_no_fire_in_solo_mode(self): - ws = uuid4() - child = _make_child(date.today() - relativedelta(years=3)) - a = _make_parent(workspace_id=ws, name="A") - b = _make_parent(workspace_id=ws, name="B") - leave_a = _make_leave(a, child.id, total=240, used=180) # left = 60 - leave_b = _make_leave(b, child.id, total=240, used=230) # left = 10 - ctx = _make_ctx( - workspace_id=ws, - children=[child], - parents=[a, b], - leave_by_parent={a.uuid: leave_a, b.uuid: leave_b}, - planning_mode="solo", ) tip = await _rule_age4_pooling_loss(ctx) assert tip is None diff --git a/backend/tests/unit/test_workspace_service.py b/backend/tests/unit/test_workspace_service.py index 6a0e43a..41f4f79 100644 --- a/backend/tests/unit/test_workspace_service.py +++ b/backend/tests/unit/test_workspace_service.py @@ -1,7 +1,9 @@ """Tests for the Workspace service. -Covers workspace creation/update, with a focus on the `planning_mode` field -(explicit solo/couple planning intent — not inferred from member count), plus +Covers workspace creation/update (name only — whether a family plans solo or +together is derived from member count, not a stored field; see +`test_allocation_service.py`/`test_allocation_service_branches.py` for the +allocator-side "couple wish-assignment gates on parent count" coverage), plus renaming/removing a placeholder partner (`rename_member` / `remove_member`). """ @@ -49,64 +51,23 @@ async def _make_inviter(user_repo: SQLiteUserRepository, personal_data_uuid: UUI return user.id -class TestWorkspacePlanningMode: - """Test planning_mode defaults and validation.""" +class TestWorkspaceUpdate: + """Workspace name updates (planning mode is no longer a stored field).""" - async def test_create_workspace_defaults_to_solo( + async def test_update_name( self, workspace_service: WorkspaceService, ) -> None: - """A newly created workspace defaults to solo planning mode.""" - ws = await workspace_service.create_workspace("Test WS") - assert ws.planning_mode == "solo" - - async def test_update_planning_mode_to_couple( - self, - workspace_service: WorkspaceService, - ) -> None: - """Updating planning_mode to a valid value ('couple') succeeds.""" - ws = await workspace_service.create_workspace("Test WS") - updated = await workspace_service.update_workspace(ws.id, planning_mode="couple") - assert updated.planning_mode == "couple" - - async def test_update_planning_mode_back_to_solo( - self, - workspace_service: WorkspaceService, - ) -> None: - """Updating planning_mode back to 'solo' succeeds.""" - ws = await workspace_service.create_workspace("Test WS") - await workspace_service.update_workspace(ws.id, planning_mode="couple") - updated = await workspace_service.update_workspace(ws.id, planning_mode="solo") - assert updated.planning_mode == "solo" - - async def test_update_planning_mode_invalid_raises( - self, - workspace_service: WorkspaceService, - ) -> None: - """An invalid planning_mode value raises ValidationError.""" - ws = await workspace_service.create_workspace("Test WS") - with pytest.raises(ValidationError, match="planning_mode must be one of"): - await workspace_service.update_workspace(ws.id, planning_mode="throuple") - - async def test_update_planning_mode_missing_workspace_raises( - self, - workspace_service: WorkspaceService, - ) -> None: - """Updating planning_mode on a missing workspace raises NotFoundError.""" - with pytest.raises(NotFoundError): - await workspace_service.update_workspace(uuid4(), planning_mode="couple") - - async def test_update_name_and_planning_mode_together( - self, - workspace_service: WorkspaceService, - ) -> None: - """Multiple fields can be updated in the same call.""" ws = await workspace_service.create_workspace("Old Name") - updated = await workspace_service.update_workspace( - ws.id, name="New Name", planning_mode="couple" - ) + updated = await workspace_service.update_workspace(ws.id, name="New Name") assert updated.name == "New Name" - assert updated.planning_mode == "couple" + + async def test_update_missing_workspace_raises_not_found( + self, + workspace_service: WorkspaceService, + ) -> None: + with pytest.raises(NotFoundError): + await workspace_service.update_workspace(uuid4(), name="Whatever") class TestRenameMember: @@ -182,6 +143,20 @@ class TestRenameMember: with pytest.raises(ValidationError, match="empty or whitespace-only"): await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, " ") + async def test_add_placeholder_beyond_two_parents_raises_validation_error( + self, + workspace_service: WorkspaceService, + seeded_ids: SeededIds, + ) -> None: + """A workspace is capped at 2 parents — `seeded_ids.personal_data_uuid` + (1) plus one placeholder (2) already fill it; a third is refused.""" + await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Sam") + with pytest.raises(ValidationError, match="maximum number of parents"): + await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Kim") + + members = await workspace_service.list_members(seeded_ids.workspace_id) + assert {m.name for m in members} == {"Test Parent", "Sam"} + class TestRemoveMember: """Remove (soft-delete) a placeholder partner, guarding against orphaned data.""" diff --git a/ideas.md b/ideas.md index 0e83f65..e8386ce 100644 --- a/ideas.md +++ b/ideas.md @@ -19,6 +19,8 @@ against Försäkringskassan before a number is written down. **Why it matters:** the app silently halves a real user's entitlement. +**Update (2026-09-19):** Addressed. One member = sole parent (480 days, 96 savable at age 4); two members = 240 each (48 savable at age 4 per parent). Entitlement follows parent count, and onboarding asks explicitly ("Bara jag" / "Vi är två föräldrar"). + ## 2. Multi-child is half-built The tips engine now runs per child. The rest does not really. `TipsContext` was single-child until @@ -72,6 +74,8 @@ ordering still front-loads the friction. **Suggestion:** consider letting the user reach a working calendar first and prompting for real balances once they have a plan worth making accurate. Worth an A/B if this ever gets real users. +**Update (2026-09-19):** Step 3 now presents per-parent cards (one or two, depending on household size) with a "Klar" (Done) button that is always enabled, letting the user advance without entering data first. Skip copy was corrected. + ## 7. Vacation is modelled more thinly than parental leave Parental-leave days have tiers, reserved portions, transferability, dubbeldagar and an age-4 diff --git a/observability-feature.md b/observability-feature.md new file mode 100644 index 0000000..aac51e1 --- /dev/null +++ b/observability-feature.md @@ -0,0 +1,453 @@ +# New observability feature + +Collect errors and crashes from frontend and backend, run nightly batch analysis via Opus to categorize and suggest fixes, and surface tickets for manual resolution. + +## High-level flow + +1. **Capture**: Frontend/backend emit errors → POST to `/errors` endpoint → stored in SQLite +2. **Cleanup**: When a ticket is created to address an error group, delete the raw error logs for that group +3. **Nightly categorization job**: Query new errors → classify by category +4. **Nightly analysis job**: Group errors by category + location → send to Opus via Batches API → generate tickets +5. **Frontend dashboard**: View tickets, filter, search, edit, delete + +--- + +## 1. Frontend error capture + +### TypeScript SDK + +Lightweight error capture library (part of Föräldraledig Plannerare React/Vite frontend). + +**Functionality:** +- Catch global errors: `window.onerror`, `unhandledrejection` +- Provide explicit `captureError(error, context?)` function for manual logging +- Attach context: current URL, user session ID (if available), browser/device info +- Queue errors with exponential backoff + retry logic before POSTing +- POST to the app's existing `/errors` endpoint (backend defined below) +- No external dependencies; vanilla TypeScript + +**Captured fields:** +- `error_message` (e.g., "Cannot read property 'x' of undefined") +- `stack_trace` (full JS stack) +- `url` (current page URL) +- `user_agent` (browser + OS) +- `timestamp` +- `session_id` (if available) +- `environment` (e.g., "production", "staging") + +--- + +## 2. Backend error endpoint + +### `/errors` POST endpoint + +**Location:** Föräldraledig Plannerare existing backend + +**Request body:** +```json +{ + "error_message": "string", + "stack_trace": "string", + "url": "string", + "user_agent": "string", + "timestamp": "ISO8601", + "session_id": "string or null", + "environment": "string" +} +``` + +**Behavior:** +- Validate and log the error +- Store in SQLite `errors` table (schema below) +- Return 200 OK immediately (don't block the client) +- Optionally rate-limit per session_id or IP to prevent spam + +--- + +## 3. SQLite schema + +### `errors` table + +```sql +CREATE TABLE errors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, -- ISO8601, when error occurred + environment TEXT NOT NULL, -- 'production', 'staging', etc. + error_message TEXT NOT NULL, -- e.g., "Cannot read property 'x'" + stack_trace TEXT NOT NULL, -- Full stack trace + endpoint TEXT, -- URL or backend route (e.g., '/api/users') + location TEXT, -- File:line or component name (extracted from stack) + user_agent TEXT, -- Browser + OS + session_id TEXT, -- User session identifier + error_level TEXT, -- 'error', 'warning', 'fatal' + error_type TEXT, -- 'frontend' or 'backend' + severity INTEGER DEFAULT 1, -- 1=low, 2=medium, 3=high (Opus can set this) + error_hash TEXT, -- SHA256(stack_trace) for quick deduplication + first_seen TEXT, -- ISO8601, first occurrence of this group + last_seen TEXT, -- ISO8601, most recent occurrence + occurrence_count INTEGER DEFAULT 1, -- How many times in the time window + category TEXT, -- Set by categorization job (NULL initially) + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + has_ticket INTEGER DEFAULT 0 -- 1 if a ticket exists for this error group, 0 otherwise +); + +CREATE INDEX idx_timestamp ON errors(timestamp); +CREATE INDEX idx_category ON errors(category); +CREATE INDEX idx_error_hash ON errors(error_hash); +CREATE INDEX idx_has_ticket ON errors(has_ticket); +``` + +### `tickets` table + +```sql +CREATE TABLE tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, -- Auto-generated by Opus, editable by user + description TEXT NOT NULL, -- How to fix, what changed, Opus analysis + error_category TEXT NOT NULL, -- The category this ticket addresses + error_location TEXT NOT NULL, -- Where in the code (e.g., 'frontend/auth', 'backend/api/users') + error_type TEXT NOT NULL, -- 'frontend' or 'backend' + error_count INTEGER, -- Total occurrences in the period + opus_suggestion TEXT, -- Raw suggestion from Opus + code_context TEXT, -- Git diff, recent commits, relevant file snippets + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'open', -- 'open', 'in_progress', 'resolved', 'ignored' + resolved_at TEXT, -- When marked resolved + related_errors TEXT -- JSON array of error IDs this ticket groups +); + +CREATE INDEX idx_status ON tickets(status); +CREATE INDEX idx_created_at ON tickets(created_at); +``` + +--- + +## 4. Predefined error categories + +The categorization job uses these predefined categories as a starting point. **Opus is encouraged to suggest new categories** if errors don't fit these patterns. + +**Predefined categories:** + +1. **Auth failure** — Authentication, authorization, token expiry, invalid credentials +2. **Null/undefined reference** — Null pointer, undefined property access, type mismatches +3. **Network error** — Fetch/HTTP failures, timeouts, 4xx/5xx responses, DNS issues +4. **Rate limit** — 429 responses, quota exceeded, throttling +5. **Validation error** — Input validation, schema mismatches, malformed data +6. **Resource not found** — 404, missing file/endpoint, nonexistent record +7. **Unhandled exception** — Unexpected errors, missing error handlers, caught but not handled +8. **Performance/timeout** — Slow operations, stack overflow, memory issues, request timeouts +9. **Data integrity** — Constraint violations, inconsistent state, corrupted records +10. **Configuration/setup** — Missing env vars, incorrect configuration, initialization failures + +**New categories:** If Opus encounters errors that don't fit these, it should suggest a new category in the ticket description. Over time, review and add new predefined categories. + +--- + +## 5. Nightly categorization job + +**Frequency:** Nightly (e.g., 1 AM) + +**Trigger:** Manually via cron or scheduled task runner (e.g., `APScheduler` in Python) + +**Steps:** + +1. Query all errors where `category IS NULL` and `created_at` >= last run timestamp +2. For each error: + - Extract likely category from `error_message`, `stack_trace`, and `error_level` + - Use simple regex or rule-based matching to assign a predefined category + - If no match, leave `category = NULL` for the analysis job to handle +3. Commit changes to `errors.category` + +**Example logic:** +- If message contains "Unauthorized" or "403" → "Auth failure" +- If message contains "null is not an object" or "Cannot read property" → "Null/undefined reference" +- If message contains "timeout" or "ECONNREFUSED" → "Network error" +- If HTTP status 429 → "Rate limit" + +--- + +## 6. Nightly analysis and ticket generation job + +**Frequency:** Nightly (e.g., 1:30 AM, after categorization job) + +**Tool:** Python with Anthropic SDK, using the Batches API for cost efficiency + +**Steps:** + +### 6.1 Group errors + +Query all errors where `has_ticket = 0` and `category IS NOT NULL` (use categorization job results). + +**Grouping strategy:** Errors are grouped by **`(category, error_type, location)`** to avoid treating the same class of error (e.g., "Null/undefined reference in frontend/auth") as separate issues across occurrences. + +- `category`: The classified error type (e.g., "Null/undefined reference") +- `error_type`: Whether error originated in frontend or backend +- `location`: Where in the code the error occurred (extracted from stack trace, e.g., `components/LoginForm.tsx` or `services/user_service.py`) + +**Deduplication within a group:** +- Count total occurrences of errors in this group (sum `occurrence_count` from all matching error rows) +- Collect **representative error instances** (e.g., top 3 most recent or most severe) +- Discard duplicate raw instances within the group to avoid redundant analysis +- **Opus sees only a count + sample errors, not every instance** + +**Output:** A list of error groups, each with: +- `category` +- `error_type` +- `location` +- `total_count` (total occurrences) +- `sample_errors` (list of representative error details) +- `time_window` (when these errors occurred) + +### 6.2 Gather context for Opus + +For each error group, collect **surrounding code and git context** so Opus can make informed suggestions: + +**For frontend errors:** +- The exact error messages and stack traces from the sample errors +- Extract file path + line number from stack trace +- Fetch the relevant code snippet from the file (±10 lines from the error location) +- Get recent commits affecting that file: `git log --oneline -n 10 -- ` +- Get git diff of recent changes: `git diff HEAD~5 -- ` (last 5 commits) +- Browser/environment info from user_agent + +**For backend errors:** +- The exact error messages and stack traces +- Extract file path + line number from stack trace +- Fetch the relevant code snippet from the file (±10 lines from the error location) +- Get recent commits affecting that file: `git log --oneline -n 10 -- ` +- Get git diff of recent changes: `git diff HEAD~5 -- ` +- Related logs or request context (if available) + +**Data structure sent to Opus:** +```json +{ + "category": "Null/undefined reference", + "error_type": "frontend", + "location": "components/LoginForm.tsx", + "total_count": 47, + "time_window": "2026-09-18 to 2026-09-19", + "sample_errors": [ + { + "error_message": "Cannot read property 'email' of undefined", + "stack_trace": "...", + "timestamp": "2026-09-19T02:15:00Z", + "url": "http://app/login" + }, + { + "error_message": "Cannot read property 'email' of undefined", + "stack_trace": "...", + "timestamp": "2026-09-19T01:30:00Z", + "url": "http://app/login" + } + ], + "code_context": { + "file": "components/LoginForm.tsx", + "snippet": "const LoginForm = ({ user }) => {\n return
{user.email}
;\n}", + "recent_commits": [ + "abc1234 - Fix login form prop handling", + "def5678 - Update auth flow" + ], + "git_diff": "..." + } +} +``` + +### 6.3 Send to Opus via Batches API + +Batch all error groups into a single Batches API request (for cost efficiency). + +**Prompt for Opus:** + +``` +You are analyzing error logs from a web application (Föräldraledig Plannerare). +For each error group below, provide: + +Root cause analysis +Suggested fix (code changes or steps) +Affected systems/users +Priority (low, medium, high) + +Error group: +{error data from 6.2} + +Respond in JSON format: +{ +"title": "Brief title for the issue", +"root_cause": "What caused this", +"suggested_fix": "How to fix it (code changes or steps)", +"affected_scope": "Frontend/backend, number of users, systems impacted", +"priority": "low|medium|high", +"reasoning": "Why this priority" +} +``` + + +### 6.4 Parse Opus response and create tickets + +For each error group with Opus analysis: + +1. Create a row in `tickets` table with: + - `title` = Opus's suggested title + - `description` = Combination of root cause, suggested fix, and affected scope + - `error_category` = The error group's category + - `error_location` = The file/component location + - `error_type` = frontend or backend + - `error_count` = total_count from the group + - `opus_suggestion` = Full raw response from Opus + - `code_context` = Git diff and file snippets (for reference) + - `related_errors` = JSON array of error IDs in this group + +2. Update all error rows in the group: `has_ticket = 1` + +3. **After ticket creation:** Delete all error rows where `has_ticket = 1` (cleanup raw errors once addressed by a ticket) + +--- + +## 7. Frontend dashboard MVP + +**Technology:** React + Vite (part of Föräldraledig Plannerare frontend) + +**Access:** Run with `npm run observability` or similar command + +**Features:** + +### View +- **Tickets list**: All open/in-progress tickets (paginated, ~20 per page) +- **Ticket detail**: Expand to show full description, error context, code snippets, priority +- **Filter**: + - By status: open, in_progress, resolved, ignored + - By error_type: frontend, backend + - By error_category: (all predefined categories + custom) + - By date range +- **Search**: By ticket title or description (substring match) +- **Trends chart**: Line chart showing error count over time (last 7 days) + +### Actions +- **Edit ticket**: User can update `title` and `description` (e.g., clarify the fix, add notes) +- **Change status**: Mark as in_progress, resolved, ignored +- **Delete ticket**: Remove a bad or duplicate ticket +- **View related errors**: Show the raw error logs (with timestamps, counts) that this ticket groups + +### Data source +- All ticket data queried from backend `/tickets` GET endpoint (with optional filters) +- All error data queried from backend `/errors` GET endpoint (with filters) + +--- + +## 8. Backend API endpoints (existing app backend) + +### `POST /errors` +*Receive error reports from the frontend SDK* + +Request: Error object (see Section 2) +Response: `{ success: true }` + +--- + +### `GET /tickets` +*Fetch tickets with optional filters* + +Query params: +- `status` (open, in_progress, resolved, ignored) +- `error_type` (frontend, backend) +- `error_category` (predefined category name) +- `date_from` (ISO8601) +- `date_to` (ISO8601) +- `search` (substring search in title/description) +- `page` (pagination, default 1) +- `limit` (per page, default 20) + +Response: +```json +{ + "tickets": [ + { + "id": 1, + "title": "Login form crashes on null user object", + "description": "...", + "error_category": "Null/undefined reference", + "error_type": "frontend", + "error_count": 47, + "status": "open", + "created_at": "2026-09-19T01:45:00Z", + "code_context": "..." + } + ], + "total": 42, + "page": 1 +} +``` + +--- + +### `GET /errors` +*Fetch raw error logs (for dashboard context)* + +Query params: +- `ticket_id` (optional; show errors related to a specific ticket) +- `category` (optional; filter by category) +- `error_type` (optional; frontend or backend) +- `date_from`, `date_to` (optional; filter by date range) +- `has_ticket` (optional; 0 or 1) +- `limit` (default 100) + +Response: +```json +{ + "errors": [ + { + "id": 123, + "timestamp": "2026-09-19T02:15:00Z", + "error_message": "Cannot read property 'email' of undefined", + "location": "components/LoginForm.tsx", + "occurrence_count": 5, + "category": "Null/undefined reference" + } + ], + "total": 200 +} +``` + +--- + +### `PATCH /tickets/:id` +*Update a ticket (title, description, status)* + +Request: +```json +{ + "title": "Updated title", + "description": "Updated description", + "status": "in_progress" +} +``` + +Response: Updated ticket object + +--- + +### `DELETE /tickets/:id` +*Delete a ticket* + +Response: `{ success: true }` + +After deletion, the related error rows should have `has_ticket = 0` set again (so they can be re-analyzed in a future batch run if desired). + +--- + +## 9. Summary of jobs and scheduling + +| Job | Frequency | Trigger | Input | Output | +|-----|-----------|---------|-------|--------| +| **Categorization** | Nightly (1 AM) | Cron/scheduler | Uncategorized errors | Errors with `category` set | +| **Analysis + ticket generation** | Nightly (1:30 AM) | Cron/scheduler | Categorized errors + git context | New tickets, deletion of raw errors with tickets | + +--- + +## 10. Implementation notes + +- **Python setup**: Use Anthropic Python SDK for Batches API calls +- **Scheduling**: Use APScheduler or similar to run jobs on a schedule +- **Error deduplication**: Use SHA256(stack_trace) for quick lookups; grouping by (category, error_type, location) for analysis +- **Cost efficiency**: Batches API ensures Opus analysis is cost-effective at scale +- **Self-hosting**: All components (SDKs, backend, jobs, frontend) are self-hosted on the existing Föräldraledig Plannerare infrastructure \ No newline at end of file