From 249e277696c1895eab908afcd02d91cb72ee40b5 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 14 Sep 2026 07:38:07 +0200 Subject: [PATCH] Fix flexible-wish double-booking, extend calendar cache to 2055, add journey tests Second real bug found by driving the running app while the suites were green. Flexible wishes were funded twice across propose -> apply -> re-propose. Because planned_leave_day carries no wish_id, the engine could not tell that applied days were that wish's own prior occurrence; it treated them as occupied space, routed the candidate search around them, and funded the year again on shifted dates while reporting weeks_funded 1.0. Applying twice turned "one week each spring" into 18 paid days instead of 9. Fixed wishes were unaffected and stay unaffected. Fix (allocation_service.py), per the approved approach of counting already-applied days inside the bounding range rather than adding a wish_id column: - _Ledger._persisted_lookup snapshots existing days BEFORE this run folds in its own freshly-computed windows, so a fixed wish's new placement is never mistaken for a flexible wish's history - _reuse_already_funded_window finds this wish's persisted dates within its bound and reuses the window containing them, via the ordinary fund_window path - full funding therefore yields zero new days and partial funding tops up exactly the remainder, with no duplication and no false shortfall - with no persisted dates, or if the reused window is no longer collision-free, it falls through to the original search, so an unsatisfied year is never frozen Calendar cache extended 2026-2035 -> 2026-2055 (39KB -> 116KB) and the allocator switched to strict, so an uncached year raises UncachedYearError -> ValidationError -> HTTP 422 instead of silently treating Julafton, Nyarsafton and Midsommarafton as working days. Also fixed normalize_day dropping the second name when two holidays share a date (Nationaldagen was being lost). 2049 and 2055 legitimately have 17 red days, not 18: Pingstdagen coincides with Nationaldagen. New backend/tests/integration/test_user_journey.py drives the real app over HTTP across seven journeys: the double-booking guard, the fixed-wish control, apply idempotency, fixed-never-displaced-by-flexible, flexible wishes tracking Easter per year, age-4 arithmetic self-consistency, and a recursive check that no Swedish text reaches the wire now that everything is message_key + data. New backend/scripts/seed_demo.py seeds a realistic workspace through the real service layer for manual testing: demo@example.com / DemoPass123! Backend 414 -> 435 tests. Frontend unchanged at 705. Still not verified in a browser. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL --- backend/README.md | 18 + backend/app/data/swedish_calendar.json | 2942 +++++++++++++++++ backend/app/routers/plan.py | 4 +- backend/app/services/allocation_service.py | 161 +- backend/app/services/calendar_service.py | 4 +- backend/app/utils/holidays.py | 23 +- backend/scripts/fetch_swedish_calendar.py | 25 +- backend/scripts/seed_demo.py | 442 +++ backend/tests/integration/__init__.py | 0 .../tests/integration/test_user_journey.py | 702 ++++ backend/tests/unit/test_calendar_service.py | 35 +- backend/tests/unit/test_holidays.py | 28 +- .../test_allocation_input_loader.py | 118 +- .../test_services/test_allocation_service.py | 242 ++ .../tests/unit/test_swedish_calendar_cache.py | 137 +- 15 files changed, 4828 insertions(+), 53 deletions(-) create mode 100644 backend/scripts/seed_demo.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/test_user_journey.py diff --git a/backend/README.md b/backend/README.md index 45940a4..805befd 100644 --- a/backend/README.md +++ b/backend/README.md @@ -29,6 +29,24 @@ app/ utils/ # errors, logger ``` +## Demo data (manual/browser testing) + +`scripts/seed_demo.py` seeds a realistic, ready-to-explore workspace through the +normal service/repository layers (no raw SQL) — a couple workspace with a +partner, a child born ~6 months ago, vacation rows for only some years (so the +`is_assumed` badge is visible), day overrides, and a spread of leave wishes +(fixed, flexible, and one deliberately underfunded to show a shortfall + +remedies + surplus advice). Login credentials are fixed and printed at the end. + +```bash +uv run python scripts/seed_demo.py # create once; re-running is a no-op +uv run python scripts/seed_demo.py --reset # wipe the demo workspace and rebuild +``` + +Then open `http://localhost:5173` and log in with the printed `demo@example.com` +credentials. Safe to re-run: without `--reset` it detects the existing demo +user and changes nothing; `--reset` only ever deletes the one demo workspace. + ## Reference vertical slice `VacationDays` is implemented end-to-end (entity → schema → repo interface → diff --git a/backend/app/data/swedish_calendar.json b/backend/app/data/swedish_calendar.json index c2314a1..4cea8ca 100644 --- a/backend/app/data/swedish_calendar.json +++ b/backend/app/data/swedish_calendar.json @@ -1491,6 +1491,2948 @@ } ], "year": 2035 + }, + { + "days": [ + { + "category": "public", + "date": "2036-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2036-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2036-04-11", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2036-04-12", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2036-04-13", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2036-04-14", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2036-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2036-05-02", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2036-05-22", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2036-05-23", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2036-05-31", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2036-06-01", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2036-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2036-06-20", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2036-06-21", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2036-11-01", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2036-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2036-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2036-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2036-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2036 + }, + { + "days": [ + { + "category": "public", + "date": "2037-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2037-01-02", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "bridge_day", + "date": "2037-01-05", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2037-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2037-04-03", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2037-04-04", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2037-04-05", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2037-04-06", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2037-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2037-05-14", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2037-05-15", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2037-05-23", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2037-05-24", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2037-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2037-06-19", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2037-06-20", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2037-10-31", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2037-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2037-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2037-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2037-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2037 + }, + { + "days": [ + { + "category": "public", + "date": "2038-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2038-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2038-04-23", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2038-04-24", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2038-04-25", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2038-04-26", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2038-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2038-06-03", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2038-06-04", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2038-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2038-06-12", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2038-06-13", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2038-06-25", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2038-06-26", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2038-11-06", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2038-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2038-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2038-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2038-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2038 + }, + { + "days": [ + { + "category": "public", + "date": "2039-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2039-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2039-01-07", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2039-04-08", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2039-04-09", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2039-04-10", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2039-04-11", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2039-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2039-05-19", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2039-05-20", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2039-05-28", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2039-05-29", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2039-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2039-06-24", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2039-06-25", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2039-11-05", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2039-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2039-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2039-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2039-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2039 + }, + { + "days": [ + { + "category": "public", + "date": "2040-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2040-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2040-03-30", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2040-03-31", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2040-04-01", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2040-04-02", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2040-04-30", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2040-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2040-05-10", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2040-05-11", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2040-05-19", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2040-05-20", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2040-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2040-06-22", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2040-06-23", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2040-11-03", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2040-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2040-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2040-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2040-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2040 + }, + { + "days": [ + { + "category": "public", + "date": "2041-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2041-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2041-04-19", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2041-04-20", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2041-04-21", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2041-04-22", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2041-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2041-05-30", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2041-05-31", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2041-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2041-06-07", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2041-06-08", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2041-06-09", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2041-06-21", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2041-06-22", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2041-11-02", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2041-12-23", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2041-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2041-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2041-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2041-12-27", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "bridge_day", + "date": "2041-12-30", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2041-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2041 + }, + { + "days": [ + { + "category": "public", + "date": "2042-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2042-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2042-04-04", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2042-04-05", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2042-04-06", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2042-04-07", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2042-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2042-05-02", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2042-05-15", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2042-05-16", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2042-05-24", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2042-05-25", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2042-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2042-06-20", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2042-06-21", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2042-11-01", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2042-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2042-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2042-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2042-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2042 + }, + { + "days": [ + { + "category": "public", + "date": "2043-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2043-01-02", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "bridge_day", + "date": "2043-01-05", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2043-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2043-03-27", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2043-03-28", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2043-03-29", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2043-03-30", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2043-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2043-05-07", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2043-05-08", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2043-05-16", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2043-05-17", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2043-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2043-06-19", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2043-06-20", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2043-10-31", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2043-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2043-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2043-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2043-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2043 + }, + { + "days": [ + { + "category": "public", + "date": "2044-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2044-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2044-04-15", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2044-04-16", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2044-04-17", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2044-04-18", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2044-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2044-05-26", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2044-05-27", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2044-06-04", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2044-06-05", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2044-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2044-06-24", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2044-06-25", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2044-11-05", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2044-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2044-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2044-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2044-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2044 + }, + { + "days": [ + { + "category": "public", + "date": "2045-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2045-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2045-04-07", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2045-04-08", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2045-04-09", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2045-04-10", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2045-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2045-05-18", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2045-05-19", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2045-05-27", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2045-05-28", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2045-06-05", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2045-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2045-06-23", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2045-06-24", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2045-11-04", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "public", + "date": "2045-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2045-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2045-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "public", + "date": "2045-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2045 + }, + { + "days": [ + { + "category": "public", + "date": "2046-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2046-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2046-03-23", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2046-03-24", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2046-03-25", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2046-03-26", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2046-04-30", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2046-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2046-05-02", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2046-05-03", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2046-05-04", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2046-05-12", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2046-05-13", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2046-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2046-06-22", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2046-06-23", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2046-11-03", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2046-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2046-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2046-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2046-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2046 + }, + { + "days": [ + { + "category": "public", + "date": "2047-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2047-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2047-04-12", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2047-04-13", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2047-04-14", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2047-04-15", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2047-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2047-05-23", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2047-05-24", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2047-06-01", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2047-06-02", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2047-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2047-06-07", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2047-06-21", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2047-06-22", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2047-11-02", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2047-12-23", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2047-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2047-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2047-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2047-12-27", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "bridge_day", + "date": "2047-12-30", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2047-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2047 + }, + { + "days": [ + { + "category": "public", + "date": "2048-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2048-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2048-04-03", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2048-04-04", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2048-04-05", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2048-04-06", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2048-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2048-05-14", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2048-05-15", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2048-05-23", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2048-05-24", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2048-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2048-06-19", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2048-06-20", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2048-10-31", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2048-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2048-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2048-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2048-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2048 + }, + { + "days": [ + { + "category": "public", + "date": "2049-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2049-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2049-04-16", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2049-04-17", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2049-04-18", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2049-04-19", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2049-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2049-05-27", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2049-05-28", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2049-06-05", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2049-06-06", + "name_en": "whit sunday; national day of sweden", + "name_sv": "pingstdagen; sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2049-06-25", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2049-06-26", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2049-11-06", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2049-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2049-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2049-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2049-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2049 + }, + { + "days": [ + { + "category": "public", + "date": "2050-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2050-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2050-01-07", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2050-04-08", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2050-04-09", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2050-04-10", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2050-04-11", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2050-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2050-05-19", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2050-05-20", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2050-05-28", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2050-05-29", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2050-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2050-06-24", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2050-06-25", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2050-11-05", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2050-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2050-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2050-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2050-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2050 + }, + { + "days": [ + { + "category": "public", + "date": "2051-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2051-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2051-03-31", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2051-04-01", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2051-04-02", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2051-04-03", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2051-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2051-05-11", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2051-05-12", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2051-05-20", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2051-05-21", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2051-06-05", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2051-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2051-06-23", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2051-06-24", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2051-11-04", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "public", + "date": "2051-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2051-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2051-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "public", + "date": "2051-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2051 + }, + { + "days": [ + { + "category": "public", + "date": "2052-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2052-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2052-04-19", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2052-04-20", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2052-04-21", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2052-04-22", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2052-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2052-05-30", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2052-05-31", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2052-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2052-06-07", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2052-06-08", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2052-06-09", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2052-06-21", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2052-06-22", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2052-11-02", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2052-12-23", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2052-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2052-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2052-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2052-12-27", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "bridge_day", + "date": "2052-12-30", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2052-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2052 + }, + { + "days": [ + { + "category": "public", + "date": "2053-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2053-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2053-04-04", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2053-04-05", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2053-04-06", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2053-04-07", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2053-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2053-05-02", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2053-05-15", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2053-05-16", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2053-05-24", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2053-05-25", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2053-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2053-06-20", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2053-06-21", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2053-11-01", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2053-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2053-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2053-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2053-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2053 + }, + { + "days": [ + { + "category": "public", + "date": "2054-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2054-01-02", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "bridge_day", + "date": "2054-01-05", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "public", + "date": "2054-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2054-03-27", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2054-03-28", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2054-03-29", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2054-03-30", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2054-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2054-05-07", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2054-05-08", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2054-05-16", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2054-05-17", + "name_en": "whit sunday", + "name_sv": "pingstdagen", + "working_day": false + }, + { + "category": "public", + "date": "2054-06-06", + "name_en": "national day of sweden", + "name_sv": "sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2054-06-19", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2054-06-20", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2054-10-31", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2054-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2054-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2054-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2054-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2054 + }, + { + "days": [ + { + "category": "public", + "date": "2055-01-01", + "name_en": "new year's day", + "name_sv": "nyårsdagen", + "working_day": false + }, + { + "category": "public", + "date": "2055-01-06", + "name_en": "epiphany", + "name_sv": "trettondedag jul", + "working_day": false + }, + { + "category": "public", + "date": "2055-04-16", + "name_en": "good friday", + "name_sv": "långfredagen", + "working_day": false + }, + { + "category": "de_facto", + "date": "2055-04-17", + "name_en": "easter eve", + "name_sv": "påskafton", + "working_day": false + }, + { + "category": "public", + "date": "2055-04-18", + "name_en": "easter sunday", + "name_sv": "påskdagen", + "working_day": false + }, + { + "category": "public", + "date": "2055-04-19", + "name_en": "easter monday", + "name_sv": "annandag påsk", + "working_day": false + }, + { + "category": "public", + "date": "2055-05-01", + "name_en": "may day", + "name_sv": "första maj", + "working_day": false + }, + { + "category": "public", + "date": "2055-05-27", + "name_en": "ascension day", + "name_sv": "kristi himmelsfärdsdag", + "working_day": false + }, + { + "category": "bridge_day", + "date": "2055-05-28", + "name_en": "bridge day", + "name_sv": "klämdag", + "working_day": true + }, + { + "category": "de_facto", + "date": "2055-06-05", + "name_en": "whitsun eve", + "name_sv": "pingstafton", + "working_day": false + }, + { + "category": "public", + "date": "2055-06-06", + "name_en": "whit sunday; national day of sweden", + "name_sv": "pingstdagen; sveriges nationaldag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2055-06-25", + "name_en": "midsummer eve", + "name_sv": "midsommarafton", + "working_day": false + }, + { + "category": "public", + "date": "2055-06-26", + "name_en": "midsummer day", + "name_sv": "midsommardagen", + "working_day": false + }, + { + "category": "public", + "date": "2055-11-06", + "name_en": "all saints' day", + "name_sv": "alla helgons dag", + "working_day": false + }, + { + "category": "de_facto", + "date": "2055-12-24", + "name_en": "christmas eve", + "name_sv": "julafton", + "working_day": false + }, + { + "category": "public", + "date": "2055-12-25", + "name_en": "christmas day", + "name_sv": "juldagen", + "working_day": false + }, + { + "category": "public", + "date": "2055-12-26", + "name_en": "boxing day", + "name_sv": "annandag jul", + "working_day": false + }, + { + "category": "de_facto", + "date": "2055-12-31", + "name_en": "new year's eve", + "name_sv": "nyårsafton", + "working_day": false + } + ], + "year": 2055 } ] } diff --git a/backend/app/routers/plan.py b/backend/app/routers/plan.py index eb8b5d6..6292287 100644 --- a/backend/app/routers/plan.py +++ b/backend/app/routers/plan.py @@ -16,7 +16,7 @@ from app.models.plan import ( PlanProposalResponse, PlanProposeRequest, ) -from app.utils.errors import NotFoundError +from app.utils.errors import NotFoundError, ValidationError router = APIRouter(prefix="/api/plan", tags=["plan"]) @@ -30,6 +30,8 @@ async def propose_plan( proposal = await service.propose(payload.workspace_id) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc + except ValidationError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc return PlanProposalResponse.from_domain(proposal) diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index 7f3f53c..1d3f04f 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -35,8 +35,13 @@ from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.repositories.vacation_days_repository import VacationDaysRepository from app.repositories.workspace_repository import WorkspaceRepository -from app.utils.errors import NotFoundError -from app.utils.holidays import SUNDAY_HOLIDAY_NAME, get_swedish_holidays +from app.utils.errors import NotFoundError, ValidationError +from app.utils.holidays import ( + SUNDAY_HOLIDAY_NAME, + UncachedYearError, + cached_year_bounds, + get_swedish_holidays, +) from app.utils.tip_predicates import days_left_at_age_4_projection, has_vacation_near_expiry # --------------------------------------------------------------------------- @@ -585,6 +590,18 @@ 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. + # `_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 + ) self._reserve_preschool_days() @@ -1166,18 +1183,118 @@ class _Ledger: self._existing_lookup[(d.parent_personal_data_uuid, d.child_id, d.date)] = d.day_type def _candidate_occupied( - self, start: date, end: date, parents: list[ParentFacts], child_id: UUID | None + self, + start: date, + end: date, + parents: list[ParentFacts], + child_id: UUID | None, + exclude_dates: frozenset[date] = frozenset(), ) -> bool: """§10.2 step 5 — whether `[start, end]` collides with an already-claimed date for any of `parents` (paired with `child_id`). + `exclude_dates` (§fix) lets a caller that already knows some of + those claimed dates are this very wish's own prior occurrence treat + them as expected, not a collision. """ for d in _dates_in(start, end): + if d in exclude_dates: + continue for p in parents: if (p.uuid, child_id, d) in self._existing_lookup: return True return False + def _persisted_dates_for_wish( + self, + responsible: _Responsible, + 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`). + """ + 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 + } + ) + + def _reuse_already_funded_window( + self, + wish: LeaveWish, + responsible: _Responsible, + child_id: UUID | None, + bound_start: date, + bound_end: date, + length_days: int, + ) -> tuple[date, date] | None: + """§fix — the double-booking-on-repropose fix, product-owner approved: + + if this flexible wish-year already has planned days from a prior + apply inside its bounding range, reuse the full-length window they + belong to instead of searching for a new one. The caller then funds + that window through the ordinary `fund_window()` path exactly like + any other window; because `_fund_solo`/`_fund_swap`/`_fund_both` all + skip emitting a new `ProposedDay` for a date `_existing_lookup` + already has, this single mechanism handles both "fully funded" + (every spendable day in the window is already persisted -> zero new + days) and "partially funded" (only some are -> the remainder is + topped up) without a separate code path for either. + + Known and accepted limitation (§10.2's "occupied dates" tradeoff, + now extended): a planned day belonging to a *different* wish that + happens to fall in this same bounding range cannot be told apart + from this wish's own prior occurrence, and may be mistaken for it. + + Returns `None` — falls through to a fresh search — when: nothing is + persisted for this wish-year yet, the persisted dates don't fit + inside one `length_days` window (they can't be a single occurrence + of *this* wish), or every window that would contain them now + collides with something else already claimed this run (e.g. a + higher-priority fixed wish added since the last apply). That last + 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 + ) + if not persisted_dates: + return None + + span = (persisted_dates[-1] - persisted_dates[0]).days + 1 + if span > length_days: + return None + + own_dates = frozenset(persisted_dates) + candidates = [ + (s, e) + for s, e in _candidate_windows(bound_start, bound_end, length_days) + if s <= persisted_dates[0] + and persisted_dates[-1] <= e + and not self._candidate_occupied(s, e, responsible.parents, child_id, own_dates) + ] + 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, + ), + ) + def resolve_flexible_window( self, wish: LeaveWish, anchor_year: int, bound_start: date, bound_end: date ) -> _WishWindow | None: @@ -1201,6 +1318,18 @@ class _Ledger: resolved_child = self._resolve_wish_child(wish) child_id = resolved_child.id if resolved_child is not None else None + # §fix — an already (fully or partially) funded wish-year reuses its + # own prior window rather than being searched for fresh, which is + # what caused the double-booking regression. + reused = self._reuse_already_funded_window( + wish, responsible, child_id, bound_start, bound_end, length_days + ) + if reused is not None: + winner_start, winner_end = reused + return _WishWindow( + wish=wish, anchor_year=anchor_year, start=winner_start, end=winner_end + ) + candidates = [ (s, e) for s, e in _candidate_windows(bound_start, bound_end, length_days) @@ -1826,9 +1955,31 @@ class AllocationInputLoader: is_assumed=True, ) + # `strict=True`: never silently fall back to the live `holidays` + # library for a year outside `swedish_calendar.json`'s cached range — + # that fallback is missing de facto days (Julafton/Nyårsafton/ + # Midsommarafton) and previously caused those to be charged as + # working days once a plan's horizon ran past the cache (see + # `app.utils.holidays.UncachedYearError`). With the cache now ~30 + # years deep this should never fire for a realistic horizon, but a + # wish with a far-out `last_year` (uncapped when the workspace has no + # children yet) can still in principle reach past it. Rather than + # silently clamping the horizon (which would contradict the §6 + # decision to plan past B4 and show the squeeze), surface it as a + # `ValidationError` the router turns into a 422 the UI can explain, + # the same way `CalendarService` handles an out-of-range `start_year`. holidays_map: dict[date, str] = {} - for year in range(today.year, horizon_end.year + 2): - holidays_map.update(get_swedish_holidays(year)) + try: + for year in range(today.year, horizon_end.year + 2): + holidays_map.update(get_swedish_holidays(year, strict=True)) + except UncachedYearError as exc: + bounds = cached_year_bounds() + where = f"{bounds[0]}-{bounds[1]}" if bounds is not None else "(cache missing)" + raise ValidationError( + f"Planning horizon ({horizon_end.isoformat()}) extends beyond the cached " + f"Swedish calendar {where}; shorten the wish's last_year or the plan's " + "horizon so it stays within the cached range." + ) from exc day_overrides: dict[date, bool] = {} if self._day_override_repo is not None: diff --git a/backend/app/services/calendar_service.py b/backend/app/services/calendar_service.py index 38bdb37..14b2a01 100644 --- a/backend/app/services/calendar_service.py +++ b/backend/app/services/calendar_service.py @@ -36,7 +36,7 @@ from app.utils.holidays import ( ) # Fixed page size per §3.8's product decision: paginate four years ahead at a -# time even though the full 10-year cache is available. +# time even though the full ~30-year cache is available. MAX_WINDOW_YEARS = 4 DayType = Literal["red_day", "klamdag", "weekend", "working_day"] @@ -84,7 +84,7 @@ class CalendarService: workspace's day overrides. Raises `ValidationError` for an out-of-range `years` or a - `start_year` outside the cached 10-year window (when the cache file + `start_year` outside the cached year window (when the cache file is present at all). """ if years < 1 or years > MAX_WINDOW_YEARS: diff --git a/backend/app/utils/holidays.py b/backend/app/utils/holidays.py index 4a8c238..2f13bca 100644 --- a/backend/app/utils/holidays.py +++ b/backend/app/utils/holidays.py @@ -2,7 +2,7 @@ Single source of truth for "is this day free" across the app (planning engine, tips engine). Backed by `app/data/swedish_calendar.json` — a -validated 10-year cache (2026-2035) built from the Dagsmart API and +validated ~30-year cache (2026-2055) built from the Dagsmart API and cross-checked against the `holidays` library. For years outside the cached range, falls back to the `holidays` library directly; the fallback only knows formal röda dagar (+ every Sunday), not de facto days or klämdagar. @@ -107,14 +107,17 @@ class UncachedYearError(RuntimeError): """Raised by a `strict=True` caller when `year` falls outside `swedish_calendar.json`'s range (or the cache file is missing). - This should never fire in normal operation: callers that pass - `strict=True` (currently only `CalendarService`) are expected to clamp - their requested window to `cached_year_bounds()` first. Hitting this is - therefore a bug in that clamping, not bad user input — fail loudly - instead of silently falling back to the `holidays` library, which gives - strictly poorer answers for the same dates (missing de facto days like - Julafton/Nyårsafton/Midsommarafton) and would defeat the whole point of - the cache (see module docstring; §6 offline/deterministic guarantee). + This should never fire in normal operation: `CalendarService` clamps its + requested window to `cached_year_bounds()` first, and the planning + engine's `AllocationInputLoader` surfaces this as a `ValidationError` + instead of letting it escape as a raw `RuntimeError` — see + `app.services.allocation_service.AllocationInputLoader.load`. Hitting + this in either caller is therefore a bug in that guard, not bad user + input — fail loudly instead of silently falling back to the `holidays` + library, which gives strictly poorer answers for the same dates (missing + de facto days like Julafton/Nyårsafton/Midsommarafton) and would defeat + the whole point of the cache (see module docstring; §6 offline/ + deterministic guarantee). """ @@ -205,7 +208,7 @@ def cached_year_bounds() -> tuple[int, int] | None: cache file is missing. Used by `app.services.calendar_service` to validate/paginate the - calendar-days endpoint window without hardcoding the 10-year range. + calendar-days endpoint window without hardcoding the cached-year range. """ years = _load_raw_cache() if not years: diff --git a/backend/scripts/fetch_swedish_calendar.py b/backend/scripts/fetch_swedish_calendar.py index 2799564..91f8774 100755 --- a/backend/scripts/fetch_swedish_calendar.py +++ b/backend/scripts/fetch_swedish_calendar.py @@ -24,7 +24,10 @@ import httpx # Configuration DAGSMART_API_BASE = "https://api.dagsmart.se/v3/holidays" CURRENT_YEAR = datetime.date.today().year -YEARS_TO_FETCH = 10 # Fetch 10 years forward from current year (inclusive) +YEARS_TO_FETCH = 30 # Fetch 30 years forward from current year (inclusive). +# Bump this constant (and rerun this script) whenever the planning horizon +# risks approaching the end of the cached range; see AllocationInputLoader's +# strict=True check in app/services/allocation_service.py. RATE_LIMIT_DELAY = 2.5 # 5 requests per 10 seconds; conservative margin OUTPUT_DIR = Path(__file__).parent.parent / "app" / "data" OUTPUT_FILE = OUTPUT_DIR / "swedish_calendar.json" @@ -102,22 +105,32 @@ def normalize_day(day: dict[str, Any]) -> dict[str, Any]: Normalizes category to consistent "de_facto" spelling and lowercases names. + A date can carry more than one `observances` entry when two distinct + holidays coincide on the same calendar date (e.g. Pingstdagen and + Sveriges nationaldag both land on 2049-06-06 and 2055-06-06, since + Pentecost is Easter + 49 days and National Day is fixed at June 6). All + observance names are joined with "; " — mirroring the same convention + `app/utils/holidays.py` already uses for a holiday that coincides with a + Sunday — so a date-collision year never silently loses one of the two + names the way taking only `observances[0]` would. + Args: day: Day record from Dagsmart API response Returns: Normalized day record with our schema """ - # Extract name from the first observance + # Join every observance's name (usually just one) so a same-date + # collision between two distinct holidays never drops one silently. observances = day.get("observances", []) name_sv = "Unknown" name_en = "Unknown" if observances: - primary_observance = observances[0] - name_data = primary_observance.get("name", {}) - name_sv = name_data.get("sv", "Unknown") - name_en = name_data.get("en", "Unknown") + sv_names = [o.get("name", {}).get("sv", "") for o in observances] + en_names = [o.get("name", {}).get("en", "") for o in observances] + name_sv = "; ".join(n for n in sv_names if n) or "Unknown" + name_en = "; ".join(n for n in en_names if n) or "Unknown" # Normalize category: vendor uses "defacto", we use "de_facto" category = day["holidayType"] diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py new file mode 100644 index 0000000..7a92d8e --- /dev/null +++ b/backend/scripts/seed_demo.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +"""Seed a realistic, ready-to-explore demo workspace for manual/browser testing. + +Everything below goes through the normal service/repository layers — the same +`AuthService`, `WorkspaceService`, `ChildService`, `VacationDaysService`, +`DayOverrideService`, `LeaveWishService`, `ManualFetchService` and +`PlanningService` the API routers call — so it exercises real validation and +real code paths instead of poking SQLite directly. It creates: + + 1. A user with fixed, printed credentials (see DEMO_EMAIL/DEMO_PASSWORD) + so a human can just log in at http://localhost:5173. + 2. A couple workspace with a partner (a login-less PersonalData profile, + via `WorkspaceService.add_placeholder_parent` — "solo planning for a + partner who hasn't registered", see PersonalData's docstring) so + shared/partner planning (reserved days, dubbeldagar) is visible. + 3. A child born ~6 months before the day the script is first run, so the + age-4 boundary and the whole planning horizon sit a few years out + rather than silently aging out of a hardcoded year. + 4. Vacation-days rows for only some years, so `is_assumed` (§3.2) shows up + on the years intentionally left unset. + 5. Day overrides: one WORKED override on a date the calendar treats as a + holiday, and one OFF override on Valborgsmässoafton (30 April) — which + §6 says is deliberately NOT a default day off, making it the natural + "we take it off anyway" example. + 6. Four leave wishes: two fixed recurring ("Sommar", "Jul", the latter + wrapping New Year's), one flexible spring wish bounded 03-01..05-31 + (exercises the flexible/Easter-picking resolution path, §10.2), and one + low-priority fixed wish pinned to the demo user specifically, whose FP + pool and that year's vacation are deliberately drained so a real + shortfall (and its remedies) show up in the very first proposal. + 7. A handful of already-applied planned days for the partner (who is left + with a large surplus pool) so the Dashboard/Kalender are not empty on + first load, and so `surplus_advice` (§9.2) has something to point at. + +Idempotent / re-runnable: if the demo user already exists, the script prints +the existing summary and exits without touching the database — never +destructive by default. Pass --reset to delete the existing demo workspace +(and only that workspace, cascading via FK ON DELETE CASCADE — see +`app/db/connection.py`) and recreate it from scratch. + +Usage (from backend/): + uv run python scripts/seed_demo.py # create once, or report existing + uv run python scripts/seed_demo.py --reset # wipe the demo workspace and rebuild +""" + +from __future__ import annotations + +import argparse +import asyncio +from dataclasses import dataclass +from datetime import date, timedelta +from uuid import UUID + +import aiosqlite +from dateutil.relativedelta import relativedelta + +from app.config import settings +from app.db.connection import connect, run_migrations +from app.entities.personal_data import PersonalData +from app.entities.user import User +from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository +from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository +from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository +from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository +from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository +from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository +from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository +from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository +from app.repositories_impl.sqlite.workspace_repository import ( + SQLiteWorkspaceInvitationRepository, + SQLiteWorkspaceRepository, +) +from app.services.analytics_service import AnalyticsService +from app.services.auth_service import AuthService +from app.services.child_service import ChildService +from app.services.day_override_service import DayOverrideService +from app.services.fetch_data_service import ManualFetchService +from app.services.leave_wish_service import LeaveWishService +from app.services.planning_service import PlanningService +from app.services.vacation_days_service import VacationDaysService +from app.services.workspace_service import WorkspaceService +from app.utils.holidays import SUNDAY_HOLIDAY_NAME, get_swedish_holidays + +DEMO_EMAIL = "demo@example.com" +DEMO_PASSWORD = "DemoPass123!" # dev-only, printed below — never a real secret +DEMO_NAME = "Alex" +PARTNER_NAME = "Sam" +CHILD_NAME = "Nova" +FRONTEND_URL = "http://localhost:5173" + + +@dataclass +class DemoServices: + """Every service the seeder needs, wired the same way `dependencies.py` does.""" + + auth: AuthService + workspace: WorkspaceService + child: ChildService + vacation: VacationDaysService + day_override: DayOverrideService + leave_wish: LeaveWishService + manual_fetch: ManualFetchService + planning: PlanningService + users: SQLiteUserRepository + personal_data: SQLitePersonalDataRepository + workspaces: SQLiteWorkspaceRepository + + +def _build_services(conn: aiosqlite.Connection) -> DemoServices: + users = SQLiteUserRepository(conn) + personal_data = SQLitePersonalDataRepository(conn) + workspaces = SQLiteWorkspaceRepository(conn) + invitations = SQLiteWorkspaceInvitationRepository(conn) + children = SQLiteChildRepository(conn) + vacation_repo = SQLiteVacationDaysRepository(conn) + day_overrides = SQLiteDayOverrideRepository(conn) + leave_wishes = SQLiteLeaveWishRepository(conn) + parental_leave = SQLiteParentalLeaveRepository(conn) + planned_leave = SQLitePlannedLeaveRepository(conn) + + analytics = AnalyticsService(parental_leave, planned_leave, children, personal_data) + + return DemoServices( + auth=AuthService(users, personal_data, workspaces), + workspace=WorkspaceService(workspaces, invitations, personal_data), + child=ChildService(children), + vacation=VacationDaysService(vacation_repo), + day_override=DayOverrideService(day_overrides), + leave_wish=LeaveWishService(leave_wishes), + manual_fetch=ManualFetchService(parental_leave), + planning=PlanningService(planned_leave, analytics, parental_leave), + users=users, + personal_data=personal_data, + workspaces=workspaces, + ) + + +async def _find_existing_demo_user(services: DemoServices) -> User | None: + return await services.users.get_by_email(DEMO_EMAIL) + + +async def _reset(services: DemoServices, user: User) -> None: + """Delete the demo user's workspace, cascading via FK ON DELETE CASCADE. + + Removes everything hanging off it (personal_data, the user row itself, + child, leave_wish, vacation_days, planned_leave_day, day_override, + parental_leave_fetch, workspace_invitation) — see the CASCADE chain in + app/db/connection.py. This never touches any other user's data: it only + ever deletes the one workspace resolved from the demo user's own + personal_data row. + """ + pd = await services.personal_data.get_by_id(user.personal_data_uuid) + if pd is None: + # Orphaned user row (shouldn't happen); nothing to cascade from. + return + await services.workspaces.delete(pd.workspace_id) + print(f"[reset] Deleted existing demo workspace {pd.workspace_id} (cascaded).") + + +async def _print_existing_summary(services: DemoServices, user: User) -> None: + pd = await services.personal_data.get_by_id(user.personal_data_uuid) + workspace_id = pd.workspace_id if pd is not None else None + print("Demo user already exists — nothing changed. Re-run with --reset to rebuild it.") + print(f" Email: {DEMO_EMAIL}") + print(f" Password: {DEMO_PASSWORD}") + print(f" Workspace id: {workspace_id}") + print(f" Open: {FRONTEND_URL}") + + +def _pick_worked_override_date(birth_year: int, birth_date: date) -> tuple[date, str]: + """Pick a real calendar holiday, on/after the child's birth date, to force-work. + + This is the "we must work despite the calendar" override direction (see + DayOverride's docstring), i.e. `is_working_day=True`. Looked up from the + actual holiday calendar rather than hardcoded, so it stays correct + regardless of which years the horizon covers. + """ + holidays = get_swedish_holidays(birth_year) + holidays.update(get_swedish_holidays(birth_year + 1)) + # Every Sunday is also in this map (SUNDAY_HOLIDAY_NAME) — skip those, a + # named red day/klämdag makes a far more legible override example. + candidates = sorted( + d for d, name in holidays.items() if d >= birth_date and name != SUNDAY_HOLIDAY_NAME + ) + chosen = candidates[0] + return chosen, holidays[chosen] + + +async def _seed(conn: aiosqlite.Connection, *, reset: bool) -> None: + services = _build_services(conn) + + existing = await _find_existing_demo_user(services) + if existing is not None: + if not reset: + await _print_existing_summary(services, existing) + return + await _reset(services, existing) + + today = date.today() + birth_date = today - relativedelta(months=6) + birth_year = birth_date.year + + # -- 1. User + personal workspace + PersonalData (AuthService.register) -- + _user, alex = await services.auth.register( + 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", planning_goal="balanced" + ) + + # -- 2. Partner (no login — shared/partner planning needs a second parent + # on the workspace, but nothing requires them to have registered) ------- + sam = await services.workspace.add_placeholder_parent(workspace_id, PARTNER_NAME) + + # -- 3. Child, born ~6 months ago relative to today ---------------------- + child = await services.child.create( + workspace_id=workspace_id, + name=CHILD_NAME, + birth_date=birth_date, + preschool_start_date=birth_date + relativedelta(years=1, months=4), + preschool_planning_days_per_year=6, + ) + fourth_birthday = birth_date + relativedelta(years=4) + + # -- 4. Vacation-days rows for only SOME years ----------------------- + # Alex gets birth_year (deliberately 0 — see the manual FP entry below, + # this is the year the shortfall demo bites) and birth_year+1 (a normal + # entered year, with some carried-over days). Every other year up to the + # 4th birthday is left unset on purpose so `is_assumed` shows on them. + await services.vacation.upsert( + personal_data_uuid=alex.uuid, workspace_id=workspace_id, year=birth_year, total_days=0 + ) + await services.vacation.upsert( + personal_data_uuid=alex.uuid, + workspace_id=workspace_id, + year=birth_year + 1, + total_days=20, + saved_days_in=3, + ) + # Sam gets one entered year too, rest assumed. + await services.vacation.upsert( + personal_data_uuid=sam.uuid, workspace_id=workspace_id, year=birth_year, total_days=25 + ) + + # -- 5. Manual FP snapshot for Alex, deliberately near-drained ---------- + # Narrative: Alex front-loaded almost all of their sjukpenning/lägstanivå + # days right after birth, before this planner was in use. Combined with + # the 0-day vacation year above, this guarantees the low-priority + # "Höstlov" wish below cannot be fully funded — a real, visible shortfall + # in the very first proposal, not a contrived one. + await services.manual_fetch.save_manual_entry( + personal_data_uuid=alex.uuid, + workspace_id=workspace_id, + child_id=child.id, + total_days=240, + days_used=240, + reserved_days_total=90, + 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 ----------------------------------------------- + worked_date, worked_holiday_name = _pick_worked_override_date(birth_year, birth_date) + await services.day_override.upsert( + workspace_id=workspace_id, + day=worked_date, + is_working_day=True, + note=f"Vi jobbar trots {worked_holiday_name.lower()} i år", + ) + valborg_year = birth_year if date(birth_year, 4, 30) >= birth_date else birth_year + 1 + await services.day_override.upsert( + workspace_id=workspace_id, + day=date(valborg_year, 4, 30), + is_working_day=False, + note="Vi tar ledigt på Valborgsmässoafton", + ) + + # -- 7. Leave wishes: fixed x2, flexible x1, and the deliberately-tight + # low-priority one pinned to Alex -------------------------------------- + await services.leave_wish.create( + workspace_id=workspace_id, + child_id=child.id, + name="Sommar", + window_start_month=6, + window_start_day=15, + window_end_month=7, + window_end_day=26, + target_weeks=6, + recurrence="yearly", + first_year=birth_year, + priority=1, + parent_mode="either", + ) + await services.leave_wish.create( + workspace_id=workspace_id, + child_id=child.id, + name="Jul", + window_start_month=12, + window_start_day=20, + window_end_month=1, + window_end_day=16, # wraps into January + target_weeks=4, + recurrence="yearly", + first_year=birth_year, + priority=1, + parent_mode="either", + ) + await services.leave_wish.create( + workspace_id=workspace_id, + child_id=child.id, + name="Vår", + window_start_month=3, + window_start_day=1, + window_end_month=5, + window_end_day=31, + target_weeks=1, + recurrence="yearly", + first_year=birth_year + 1, + priority=2, + parent_mode="either", + window_mode="flexible", + ) + await services.leave_wish.create( + workspace_id=workspace_id, + child_id=child.id, + name="Höstlov", + window_start_month=10, + window_start_day=25, + window_end_month=11, + window_end_day=1, + target_weeks=1, + recurrence="yearly", + first_year=birth_year, + priority=3, + parent_mode="specific", + parent_personal_data_uuid=alex.uuid, + ) + + # -- 8. A few already-applied planned days for Sam (ample balance) so + # the Dashboard/Kalender are not empty on first load -------------------- + planned_start = today + timedelta(days=7) + applied_days = 0 + for offset, day_type in ((0, "vacation"), (1, "vacation"), (2, "sjukpenning")): + await services.planning.add_planned_day( + workspace_id=workspace_id, + child_id=child.id, + parent_personal_data_uuid=sam.uuid, + date=planned_start + timedelta(days=offset), + day_type=day_type, + ) + applied_days += 1 + + _print_summary( + workspace_id=workspace_id, + child_id=child.id, + alex=alex, + sam=sam, + birth_date=birth_date, + fourth_birthday=fourth_birthday, + worked_override=(worked_date, worked_holiday_name), + valborg_year=valborg_year, + applied_days=applied_days, + ) + + +def _print_summary( + *, + workspace_id: UUID, + child_id: UUID, + alex: PersonalData, + sam: PersonalData, + birth_date: date, + fourth_birthday: date, + worked_override: tuple[date, str], + valborg_year: int, + applied_days: int, +) -> None: + worked_date, worked_name = worked_override + print("=" * 78) + print("DEMO WORKSPACE SEEDED") + print("=" * 78) + print() + print("Login credentials:") + print(f" Email: {DEMO_EMAIL}") + print(f" Password: {DEMO_PASSWORD}") + print() + print("Created:") + print(f" Workspace id: {workspace_id} (planning_mode=couple)") + 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()}") + print(f" 4th birthday {fourth_birthday.isoformat()}") + print(" Vacation: entered for 2 years per parent, other years left") + print(" unset (is_assumed badge should show on those)") + print( + f" Overrides: worked {worked_date.isoformat()} ({worked_name}); " + f"off {valborg_year}-04-30 (Valborgsmässoafton)" + ) + print(" Wishes: Sommar (fixed, either), Jul (fixed, either, wraps NY),") + print(" Vår (flexible, either, 03-01..05-31), Höstlov (fixed,") + print(f" specific->{DEMO_NAME}, deliberately underfunded -> shortfall)") + print(f" Applied days: {applied_days} planned_leave_day rows for {PARTNER_NAME}") + print() + print(f"Open: {FRONTEND_URL}") + + +async def main() -> None: + parser = argparse.ArgumentParser( + description="Seed a realistic, ready-to-explore demo workspace." + ) + parser.add_argument( + "--reset", + action="store_true", + help="Delete the existing demo workspace (only that one) and recreate it.", + ) + args = parser.parse_args() + + if settings.database_type != "sqlite": + raise SystemExit( + f"seed_demo.py only supports sqlite (database_type={settings.database_type!r})" + ) + + # Defensive: a fresh checkout with no server ever run yet would have no + # schema. The dev server does this same call on every boot (see + # app/main.py's lifespan) — it's a no-op once already at head. + await asyncio.to_thread(run_migrations, f"sqlite:///{settings.sqlite_path}") + + conn = await connect(settings.sqlite_path) + try: + await _seed(conn, reset=args.reset) + finally: + await conn.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/test_user_journey.py b/backend/tests/integration/test_user_journey.py new file mode 100644 index 0000000..f187af8 --- /dev/null +++ b/backend/tests/integration/test_user_journey.py @@ -0,0 +1,702 @@ +"""API-level end-to-end journey tests. + +Two real bugs were found in one session by driving the *running* app through +a browser while 1,119 unit/service/router tests sat green. Both were +whole-journey bugs: every existing test exercised a single layer (a service +in isolation, or a router with hand-built repository rows), so nothing ever +walked register -> child -> vacation days -> wishes -> propose -> apply -> +re-propose the way a real user (and the browser session that found the +bugs) does. + +This file closes that gap. Every test here drives the real FastAPI app +(`app.main.app`) over HTTP with `httpx.ASGITransport`, authenticating with a +real register -> bearer-token flow (see `_register`), and only the DB +connection is swapped for the in-memory `connection` fixture from +`tests/conftest.py` (via the `get_connection` dependency override — the same +technique `tests/unit/test_plan_router.py` uses). + +Dates are always derived from `date.today()` (`next_year`, +`relativedelta(months=...)`, ...) or from `cached_year_bounds()` / +`dateutil.easter.easter()` — never a hardcoded year — so these tests stay +meaningful next year and as the Swedish-calendar cache is regenerated with a +different range. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any +from uuid import UUID + +import aiosqlite +import pytest_asyncio +from dateutil.easter import easter +from dateutil.relativedelta import relativedelta +from httpx import ASGITransport, AsyncClient + +from app.dependencies import get_connection +from app.main import app +from app.utils.holidays import cached_year_bounds + +_PASSWORD = "correct horse battery staple" + + +@pytest_asyncio.fixture +async def client(connection: aiosqlite.Connection) -> AsyncClient: + """An httpx client against the real app, with `get_connection` overridden + + to hand back the test's in-memory connection instead of opening the real + on-disk database via the (never-run) lifespan. Same technique as + `tests/unit/test_plan_router.py`. + """ + app.dependency_overrides[get_connection] = lambda: connection + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + app.dependency_overrides.pop(get_connection, None) + + +# --------------------------------------------------------------------------- +# Journey helpers — each wraps one HTTP call the real app exposes. +# --------------------------------------------------------------------------- + + +async def _register( + client: AsyncClient, email: str, display_name: str +) -> tuple[dict[str, str], UUID, UUID]: + """Register a brand-new user and return (auth headers, workspace_id, + + personal_data_uuid) — `AuthService.register` creates the workspace and + the registering parent's `PersonalData` row as a side effect, exactly as + the real onboarding flow does. + """ + resp = await client.post( + "/api/auth/register", + json={"email": email, "password": _PASSWORD, "display_name": display_name}, + ) + assert resp.status_code == 201, resp.text + token = resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + ctx = await client.get("/api/auth/context", headers=headers) + assert ctx.status_code == 200, ctx.text + body = ctx.json() + return headers, UUID(body["workspace_id"]), UUID(body["personal_data_uuid"]) + + +async def _add_child( + client: AsyncClient, headers: dict[str, str], workspace_id: UUID, name: str, birth_date: date +) -> UUID: + resp = await client.post( + "/api/children", + headers=headers, + json={ + "workspace_id": str(workspace_id), + "name": name, + "birth_date": birth_date.isoformat(), + }, + ) + assert resp.status_code == 201, resp.text + return UUID(resp.json()["id"]) + + +async def _set_vacation( + client: AsyncClient, + headers: dict[str, str], + personal_data_uuid: UUID, + workspace_id: UUID, + year: int, + total_days: int = 25, + saved_days_in: int = 0, +) -> None: + resp = await client.post( + "/api/vacation-days", + headers=headers, + json={ + "personal_data_uuid": str(personal_data_uuid), + "workspace_id": str(workspace_id), + "year": year, + "total_days": total_days, + "saved_days_in": saved_days_in, + }, + ) + assert resp.status_code == 201, resp.text + + +async def _set_full_parental_leave( + client: AsyncClient, + headers: dict[str, str], + personal_data_uuid: UUID, + workspace_id: UUID, + child_id: UUID, + sjukpenning_used: int = 0, + lagsta_niva_used: int = 0, +) -> None: + """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. + """ + resp = await client.post( + "/api/parental-leave/manual", + headers=headers, + json={ + "personal_data_uuid": str(personal_data_uuid), + "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, + "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 + + +async def _create_wish( + client: AsyncClient, headers: dict[str, str], **kwargs: Any +) -> dict[str, Any]: + resp = await client.post("/api/leave-wishes", headers=headers, json=kwargs) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _propose( + client: AsyncClient, headers: dict[str, str], workspace_id: UUID +) -> dict[str, Any]: + resp = await client.post( + "/api/plan/propose", headers=headers, json={"workspace_id": str(workspace_id)} + ) + assert resp.status_code == 200, resp.text + return resp.json() + + +async def _apply( + client: AsyncClient, + headers: dict[str, str], + workspace_id: UUID, + days: list[dict[str, Any]], + replace: bool = False, +) -> dict[str, Any]: + resp = await client.post( + "/api/plan/apply", + headers=headers, + json={"workspace_id": str(workspace_id), "days": days, "replace": replace}, + ) + assert resp.status_code == 200, resp.text + return resp.json() + + +def _paid_days_for_wish(proposal: dict[str, Any], wish_id: str) -> list[dict[str, Any]]: + """Proposed days belonging to `wish_id` that would actually cost a + + semesterdag/föräldrapenningdag — excludes the automatic 'free' + (weekend/red-day) filler entries a window also reports. + """ + return [d for d in proposal["days"] if d["wish_id"] == wish_id and d["day_type"] != "free"] + + +def _to_apply_item(d: dict[str, Any]) -> dict[str, Any]: + return { + "date": d["date"], + "parent_personal_data_uuid": d["parent_personal_data_uuid"], + "child_id": d["child_id"], + "day_type": d["day_type"], + "is_dubbeldag": d["is_dubbeldag"], + } + + +def _outcome_for_wish( + proposal: dict[str, Any], wish_id: str, year: int | None = None +) -> dict[str, Any]: + matches = [ + o + for o in proposal["outcomes"] + if o["wish_id"] == wish_id and (year is None or o["year"] == year) + ] + assert matches, f"no outcome for wish {wish_id} (year={year}) in {proposal['outcomes']}" + assert len(matches) == 1, f"expected exactly one outcome, got {matches}" + return matches[0] + + +_SWEDISH_CHARS = set("åäöÅÄÖ") + + +def _find_swedish_strings(obj: object, path: str = "$") -> list[str]: + """Recursively walk a decoded JSON response and collect every string + + leaf containing å/ä/ö — §3.5 requires every user-facing surface to be a + `message_key` + `data` pair instead of a pre-rendered Swedish string. + """ + found: list[str] = [] + if isinstance(obj, str): + if any(ch in _SWEDISH_CHARS for ch in obj): + found.append(f"{path} = {obj!r}") + elif isinstance(obj, dict): + for k, v in obj.items(): + found.extend(_find_swedish_strings(v, f"{path}.{k}")) + elif isinstance(obj, list): + for i, v in enumerate(obj): + found.extend(_find_swedish_strings(v, f"{path}[{i}]")) + return found + + +# --------------------------------------------------------------------------- +# 1) THE DOUBLE-BOOKING REGRESSION (confirmed bug, currently unfixed) +# --------------------------------------------------------------------------- + + +class TestFlexibleWishDoubleBookingOnRepropose: + """Guards the double-booking bug found live on 2026-09-14. + + Observed in the running app: propose -> apply -> propose again for a + *flexible* wish returns a full second week of brand-new paid days on + shifted dates (9 paid days become 18 if applied twice), while reporting + `weeks_funded: 1.0` for the "new" window. Root cause: once a flexible + window's dates are persisted, `_Ledger.resolve_flexible_window`'s + `_candidate_occupied` check sees them as occupied and searches for a + *different* full-length candidate elsewhere in the bounding range, + instead of recognising the wish-year as already funded. + + The approved fix (product-owner decision, landing separately): if a + flexible wish-year already has enough planned days inside its bounding + range, treat that year as funded and propose nothing new. + + This test asserts the CORRECT (post-fix) behaviour and is EXPECTED TO + FAIL until that fix lands — do not skip/xfail/weaken it; a red result + here is the point. + """ + + async def test_repropose_after_apply_adds_no_new_paid_days( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + headers, ws_id, pd_uuid = await _register(client, "double-booking@example.com", "Alex") + birth_date = date.today() - relativedelta(months=6) + child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) + await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) + + next_year = date.today().year + 1 + await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) + + wish = await _create_wish( + client, + headers, + workspace_id=str(ws_id), + child_id=str(child_id), + name="Sommar", + window_start_month=6, + window_start_day=1, + window_end_month=8, + window_end_day=31, + target_weeks=1, + recurrence="once", + first_year=next_year, + window_mode="flexible", + ) + wish_id = wish["id"] + + proposal_1 = await _propose(client, headers, ws_id) + outcome_1 = _outcome_for_wish(proposal_1, wish_id) + assert outcome_1["strategy"] != "unplaced" + paid_days_1 = _paid_days_for_wish(proposal_1, wish_id) + assert paid_days_1, "expected the flexible wish to be funded with at least one paid day" + applied_dates = {d["date"] for d in paid_days_1} + + all_days_1 = [d for d in proposal_1["days"] if d["wish_id"] == wish_id] + apply_result = await _apply(client, headers, ws_id, [_to_apply_item(d) for d in all_days_1]) + assert apply_result["accepted"] is True, apply_result + assert apply_result["created_count"] == len(paid_days_1) + + proposal_2 = await _propose(client, headers, ws_id) + paid_days_2 = _paid_days_for_wish(proposal_2, wish_id) + new_dates = {d["date"] for d in paid_days_2} - applied_dates + + assert new_dates == set(), ( + "Double-booking regression: re-proposing after applying a flexible " + "wish's window must add ZERO new paid days for that wish-year (the " + "already-applied dates should be recognised as funding it), but " + f"got {sorted(new_dates)} brand-new paid day(s) beyond the " + f"already-applied {sorted(applied_dates)}. This is the confirmed, " + "not-yet-fixed bug from the 2026-09-14 handoff." + ) + + +# --------------------------------------------------------------------------- +# 2) FIXED-WISH CONTROL CASE — locks in currently-correct behaviour +# --------------------------------------------------------------------------- + + +class TestFixedWishReproposeAddsNothingNew: + """Control case for the double-booking bug above: the SAME propose -> + + apply -> propose cycle for a `window_mode='fixed'` wish must return zero + new paid days on re-propose. `_fund_solo`'s `existing_lookup` check + already prevents this for fixed windows (unlike the flexible resolver's + `_candidate_occupied` check, which excludes rather than recognises + already-planned dates) — this test locks that in so a future change to + the shared funding path can't regress it. + """ + + async def test_repropose_after_apply_adds_no_new_paid_days( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + headers, ws_id, pd_uuid = await _register(client, "fixed-control@example.com", "Alex") + birth_date = date.today() - relativedelta(months=6) + child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) + await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) + + next_year = date.today().year + 1 + await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) + + wish = await _create_wish( + client, + headers, + workspace_id=str(ws_id), + child_id=str(child_id), + name="Fixed Week", + window_start_month=6, + window_start_day=1, + window_end_month=6, + window_end_day=7, + target_weeks=1, + recurrence="once", + first_year=next_year, + window_mode="fixed", + ) + wish_id = wish["id"] + + proposal_1 = await _propose(client, headers, ws_id) + outcome_1 = _outcome_for_wish(proposal_1, wish_id) + assert outcome_1["strategy"] != "unplaced" + paid_days_1 = _paid_days_for_wish(proposal_1, wish_id) + assert paid_days_1 + applied_dates = {d["date"] for d in paid_days_1} + + all_days_1 = [d for d in proposal_1["days"] if d["wish_id"] == wish_id] + apply_result = await _apply(client, headers, ws_id, [_to_apply_item(d) for d in all_days_1]) + assert apply_result["accepted"] is True, apply_result + + proposal_2 = await _propose(client, headers, ws_id) + paid_days_2 = _paid_days_for_wish(proposal_2, wish_id) + new_dates = {d["date"] for d in paid_days_2} - applied_dates + + assert new_dates == set(), ( + f"a fixed wish must never be re-funded with new dates on re-propose, " + f"got {sorted(new_dates)}" + ) + + +# --------------------------------------------------------------------------- +# 3) APPLY IDEMPOTENCY +# --------------------------------------------------------------------------- + + +class TestApplyIdempotency: + """Applying the exact same day set twice must skip, not duplicate.""" + + async def test_reapplying_identical_days_skips_instead_of_duplicating( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + headers, ws_id, pd_uuid = await _register(client, "idempotency@example.com", "Alex") + birth_date = date.today() - relativedelta(months=6) + child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) + await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) + + next_year = date.today().year + 1 + await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) + + wish = await _create_wish( + client, + headers, + workspace_id=str(ws_id), + child_id=str(child_id), + name="Idempotency Week", + window_start_month=9, + window_start_day=1, + window_end_month=9, + window_end_day=7, + target_weeks=1, + recurrence="once", + first_year=next_year, + ) + wish_id = wish["id"] + + proposal = await _propose(client, headers, ws_id) + paid_days = _paid_days_for_wish(proposal, wish_id) + assert paid_days, "expected at least one paid day to apply" + payload = [_to_apply_item(d) for d in paid_days] + + first = await _apply(client, headers, ws_id, payload) + assert first["accepted"] is True + assert first["created_count"] == len(paid_days) + assert first["skipped_identical_count"] == 0 + + second = await _apply(client, headers, ws_id, payload) + assert second["accepted"] is True, second + assert second["created_count"] == 0 + assert second["skipped_identical_count"] == len(paid_days) + assert second["replaced_count"] == 0 + assert second["collisions"] == [] + + +# --------------------------------------------------------------------------- +# 4) FIXED BEATS FLEXIBLE (§3.11.2 — fixed wishes always win) +# --------------------------------------------------------------------------- + + +class TestFixedWishNeverDisplacedByFlexible: + """A fixed wish that consumes a flexible wish's whole bounding range must + + leave the flexible one `strategy='unplaced'`, `weeks_funded=0.0` — a + flexible wish can never displace a fixed one, driven end-to-end through + the real onboarding flow (register/child/leave/vacation/wishes) rather + than hand-built repository rows. + """ + + async def test_flexible_wish_is_unplaced_when_fixed_wish_claims_its_bound( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + headers, ws_id, pd_uuid = await _register(client, "fixed-beats-flex@example.com", "Alex") + birth_date = date.today() - relativedelta(months=6) + child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) + await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) + + next_year = date.today().year + 1 + await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) + + await _create_wish( + client, + headers, + workspace_id=str(ws_id), + child_id=str(child_id), + name="Fixed", + window_start_month=1, + window_start_day=1, + window_end_month=1, + window_end_day=7, + target_weeks=1, + recurrence="once", + first_year=next_year, + window_mode="fixed", + ) + flexible = await _create_wish( + client, + headers, + workspace_id=str(ws_id), + child_id=str(child_id), + name="Flex", + window_start_month=1, + window_start_day=1, + window_end_month=1, + window_end_day=7, + target_weeks=1, + recurrence="once", + first_year=next_year, + window_mode="flexible", + ) + flexible_id = flexible["id"] + + proposal = await _propose(client, headers, ws_id) + outcome = _outcome_for_wish(proposal, flexible_id) + + assert outcome["strategy"] == "unplaced" + assert outcome["weeks_funded"] == 0.0 + assert outcome["shortfall_days"] == 7 + + +# --------------------------------------------------------------------------- +# 5) FLEXIBLE WISHES RE-PICK PER YEAR, TRACKING EASTER +# --------------------------------------------------------------------------- + + +class TestFlexibleYearlyWishTracksEasterEachYear: + """A yearly flexible spring wish (bounding range 03-01..05-31) must land + + on a different window each year, tracking Easter (verified live: 2027 + Mar 23-29, 2028 Apr 11-17, 2029 Mar 27-Apr 2 — the calendar-only cost + function in `resolve_flexible_window` favours whichever full-length + candidate contains the most red days, and Easter's Fri-Mon cluster is + the densest one in that range). Easter is derived via `dateutil.easter` + and the three years come from `cached_year_bounds()`, not hardcoded, so + this stays meaningful as the calendar cache is regenerated. + """ + + async def test_each_years_window_is_distinct_and_easter_adjacent( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + bounds = cached_year_bounds() + assert bounds is not None, "Swedish calendar cache is missing" + min_year, max_year = bounds + base_year = min_year + 1 # skip the partially-elapsed current year + years = [base_year, base_year + 1, base_year + 2] + assert years[-1] <= max_year, ( + f"calendar cache ({min_year}-{max_year}) too short to run this test; " + "it needs 3 full years starting the year after the cache's first year" + ) + + headers, ws_id, pd_uuid = await _register(client, "easter-tracking@example.com", "Alex") + birth_date = date.today() - relativedelta(years=1) + child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) + await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) + for year in years: + await _set_vacation(client, headers, pd_uuid, ws_id, year, total_days=25) + + wish = await _create_wish( + client, + headers, + workspace_id=str(ws_id), + child_id=str(child_id), + name="Varlov", + window_start_month=3, + window_start_day=1, + window_end_month=5, + window_end_day=31, + target_weeks=1, + recurrence="yearly", + first_year=years[0], + last_year=years[-1], + window_mode="flexible", + ) + wish_id = wish["id"] + + proposal = await _propose(client, headers, ws_id) + + windows: dict[int, tuple[date, date]] = {} + for year in years: + outcome = _outcome_for_wish(proposal, wish_id, year=year) + assert outcome["strategy"] != "unplaced", outcome + assert outcome["shortfall_days"] == 0, outcome + start = date.fromisoformat(outcome["start_date"]) + end = date.fromisoformat(outcome["end_date"]) + assert (end - start).days == 6 # a full 1-week (7-day) placement + windows[year] = (start, end) + + easter_sunday = easter(year) + assert start <= easter_sunday <= end, ( + f"{year}'s window {start}..{end} is not Easter-adjacent " + f"(Easter Sunday is {easter_sunday})" + ) + + start_month_days = {year: (w[0].month, w[0].day) for year, w in windows.items()} + assert len(set(start_month_days.values())) == len(years), ( + f"expected a distinct window each year, got {start_month_days}" + ) + + +# --------------------------------------------------------------------------- +# 6) NO SWEDISH ON THE WIRE (§3.5) +# --------------------------------------------------------------------------- + + +class TestNoSwedishOnTheWire: + """Every user-facing string in the propose/apply responses must be a + + `message_key` + `data` pair, never a pre-rendered Swedish string. Builds + a scenario that exercises outcomes, remedies, warnings and an apply + rejection (the message-bearing surfaces §3.5 converted), then walks the + full decoded JSON body looking for å/ä/ö in any string leaf. All wish + names created here are plain ASCII, so no field needs to be excluded + from the walk. + """ + + async def test_propose_and_apply_responses_carry_no_swedish_strings( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + headers, ws_id, pd_uuid = await _register(client, "no-swedish@example.com", "Alex") + birth_date = date.today() - relativedelta(months=6) + child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) + + # Exhaust the FP pool entirely -> guaranteed shortfall + remedies. + await _set_full_parental_leave( + client, headers, pd_uuid, ws_id, child_id, sjukpenning_used=390, lagsta_niva_used=90 + ) + + next_year = date.today().year + 1 + # No vacation in the wish's own year -> fully unfunded window. + await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=0) + # Spare vacation this year -> carry_vacation_forward remedy fires. + await _set_vacation(client, headers, pd_uuid, ws_id, date.today().year, total_days=25) + + await _create_wish( + client, + headers, + workspace_id=str(ws_id), + child_id=str(child_id), + name="Winter", + window_start_month=12, + window_start_day=1, + window_end_month=12, + window_end_day=7, + target_weeks=1, + recurrence="once", + first_year=next_year, + ) + + proposal = await _propose(client, headers, ws_id) + outcomes_with_shortfall = [o for o in proposal["outcomes"] if o["shortfall_days"] > 0] + assert outcomes_with_shortfall, "expected the wish's window to be underfunded" + assert outcomes_with_shortfall[0]["remedies"], "expected at least one shortfall remedy" + # A fully-drained FP pool means nothing is left to forecast as + # expiring at age-4, so `warnings` is legitimately empty here — the + # remedies/outcomes/data surfaces above already exercise the + # message_key + data i18n path; walking `warnings` too (even if + # empty) costs nothing and still guards it if it ever fires. + violations = _find_swedish_strings(proposal) + assert not violations, f"Swedish text leaked into /api/plan/propose response: {violations}" + + # Trigger a structured apply rejection (insufficient balance) too. + apply_result = await _apply( + client, + headers, + ws_id, + [ + { + "date": date(next_year, 6, 15).isoformat(), + "parent_personal_data_uuid": str(pd_uuid), + "child_id": str(child_id), + "day_type": "sjukpenning", + "is_dubbeldag": False, + } + ], + ) + assert apply_result["accepted"] is False + assert apply_result["rejections"], "expected the balance violation to be reported" + + violations = _find_swedish_strings(apply_result) + assert not violations, f"Swedish text leaked into /api/plan/apply response: {violations}" + + +# --------------------------------------------------------------------------- +# 7) PLAN-AWARE AGE-4 PROJECTION (§3.3) IS SELF-CONSISTENT +# --------------------------------------------------------------------------- + + +class TestAge4ProjectionArithmeticIsSelfConsistent: + """The propose response's `age4_projections` must satisfy + + `expiring_at_b4 == max(projected_left_at_b4 - 96, 0)` — after the + child's 4th birthday only 96 saved days may carry forward; anything + above that in the projected balance is what's forecast to expire. + """ + + async def test_expiring_equals_projected_minus_ceiling_clamped_at_zero( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + headers, ws_id, pd_uuid = await _register(client, "age4-projection@example.com", "Alex") + birth_date = date.today() - relativedelta(months=6) + child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) + next_year = date.today().year + 1 + await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) + + proposal = await _propose(client, headers, ws_id) + + assert proposal["age4_projections"], "expected an age4 projection for the (parent, child)" + for projection in proposal["age4_projections"]: + assert projection["child_id"] == str(child_id) + expected = max(projection["projected_left_at_b4"] - 96, 0) + assert projection["expiring_at_b4"] == expected, projection diff --git a/backend/tests/unit/test_calendar_service.py b/backend/tests/unit/test_calendar_service.py index 3138b3c..b067e67 100644 --- a/backend/tests/unit/test_calendar_service.py +++ b/backend/tests/unit/test_calendar_service.py @@ -102,9 +102,9 @@ class TestYearWindowPagination: async def test_a_middle_page_that_would_overshoot_is_also_clamped( self, calendar_service: CalendarService ) -> None: - """Same clamp, exercised one page before the very last year (the - `start_year=2034, years=4` case from real paging: 2034+3=2037 would - overshoot the 2026-2035 cache by two years).""" + """Same clamp, exercised one page before the very last year (e.g. the + `start_year=max_year-1, years=4` case from real paging, which would + overshoot the cache's end by two years without the clamp).""" bounds = cached_year_bounds() assert bounds is not None _, max_year = bounds @@ -178,13 +178,30 @@ class TestNoDegradedDataAcrossTheServedRange: async def test_red_day_count_is_consistent_across_every_served_year( self, calendar_service: CalendarService ) -> None: + """No year may be silently poorer than its neighbours (the fallback's + signature: 18 real red days in-cache vs. 13 once it kicks in). + + The one legitimate exception in a ~30-year range: Pingstdagen + (Easter + 49 days) occasionally lands on Sveriges nationaldag (fixed + June 6), e.g. 2049 and 2055. That's 17 *distinct* red dates, not a + degradation — both holidays genuinely share one calendar date, and + the API itself reports 17 for that year. Assert the exception is + exactly this coincidence, by name, rather than loosely allowing any + two counts. + """ by_year = await self._all_cached_years_red_days(calendar_service) - counts = {year: len(red_days) for year, red_days in by_year.items()} - # No year may be silently poorer than its neighbours (the fallback's - # signature: 18 real red days in-cache vs. 13 once it kicks in). - assert len(set(counts.values())) == 1, ( - f"red-day counts differ across years, one may be degraded: {counts}" - ) + for year, red_days in by_year.items(): + names_by_date = {d: name.lower() for d, name in red_days.items()} + june_6_name = next( + (n for d, n in names_by_date.items() if d.month == 6 and d.day == 6), "" + ) + pingst_meets_nationaldag = "pingstdagen" in june_6_name and "nationaldag" in june_6_name + expected = 17 if pingst_meets_nationaldag else 18 + assert len(red_days) == expected, ( + f"{year}: expected {expected} red dates " + f"({'Pingstdagen/Nationaldagen coincide' if pingst_meets_nationaldag else 'normal year'}), " + f"got {len(red_days)} — a year may be degraded: {sorted(red_days)}" + ) class TestOverrideMerging: diff --git a/backend/tests/unit/test_holidays.py b/backend/tests/unit/test_holidays.py index 213cf7a..e5a50b4 100644 --- a/backend/tests/unit/test_holidays.py +++ b/backend/tests/unit/test_holidays.py @@ -194,11 +194,11 @@ class TestKlamdag: assert not is_klamdag(datetime.date(2027, 3, 2)) def test_klamdag_detected_for_uncached_fallback_year(self) -> None: - """2040 is outside the cached range (2026-2035): 2040-04-30 (Mon) is - squeezed between Sunday 4/29 and Första maj (Tue 5/1). `is_klamdag` - is computed generically from `is_non_working_day`, so this works even - without cached klämdag data for the year.""" - assert is_klamdag(datetime.date(2040, 4, 30)) + """2060 is outside the cached range (2026-2055): 2060-01-02 (Fri) is + squeezed between Nyårsdagen (Thu 1/1) and the weekend (Sat 1/3). + `is_klamdag` is computed generically from `is_non_working_day`, so + this works even without cached klämdag data for the year.""" + assert is_klamdag(datetime.date(2060, 1, 2)) # --------------------------------------------------------------------------- @@ -234,29 +234,29 @@ class TestGetRedDays: # --------------------------------------------------------------------------- -# Fallback path for years outside the cached range (2026-2035) +# Fallback path for years outside the cached range (2026-2055) # --------------------------------------------------------------------------- class TestFallbackForUncachedYear: def test_uncached_year_still_returns_formal_holidays(self) -> None: - """2040 is outside the cache -> falls back to the `holidays` library: + """2060 is outside the cache -> falls back to the `holidays` library: formal röda dagar are still known.""" - result = get_swedish_holidays(2040) - assert datetime.date(2040, 12, 25) in result # Juldagen - assert datetime.date(2040, 1, 1) in result # Nyårsdagen (+ Söndag combo) + result = get_swedish_holidays(2060) + assert datetime.date(2060, 12, 25) in result # Juldagen + assert datetime.date(2060, 1, 1) in result # Nyårsdagen (+ Söndag combo) def test_uncached_year_does_not_know_de_facto_days(self) -> None: """Documents the fallback's known limitation: no de facto-day data exists outside the cached range, so julafton is NOT marked free.""" - assert not is_non_working_day(datetime.date(2040, 12, 24)) + assert not is_non_working_day(datetime.date(2060, 12, 24)) def test_uncached_year_easter_sunday_survives_sondag_filter(self) -> None: """The exact-match trap applies to the fallback path too.""" - red_days = get_red_days(2040) - # Easter 2040: verified against `holidays.Sweden(years=2040)`. + red_days = get_red_days(2060) + # Easter 2060: verified against `holidays.Sweden(years=2060)`. easter_sunday = next( - d for d, name in get_swedish_holidays(2040).items() if "Påskdagen" in name + d for d, name in get_swedish_holidays(2060).items() if "Påskdagen" in name ) assert easter_sunday in red_days 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 7e12c69..4848f64 100644 --- a/backend/tests/unit/test_services/test_allocation_input_loader.py +++ b/backend/tests/unit/test_services/test_allocation_input_loader.py @@ -15,6 +15,7 @@ import pytest from app.entities.child import Child from app.entities.day_override import DayOverride +from app.entities.leave_wish import LeaveWish from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.entities.personal_data import PersonalData from app.entities.planned_leave import PlannedLeaveDay @@ -29,7 +30,7 @@ from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedL from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository from app.services.allocation_service import AllocationInputLoader, Allocator, PlanningEngineService -from app.utils.errors import NotFoundError +from app.utils.errors import NotFoundError, ValidationError def _make_loader( @@ -391,3 +392,118 @@ class TestAllocationInputLoader: date(2027, 12, 24): True, date(2027, 2, 2): False, } + + async def test_load_costs_julafton_correctly_for_a_far_future_horizon( + 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: + """Before the cache was extended to ~30 years, a horizon reaching the + late 2040s ran past the old 10-year cache (2026-2035) and the loader + silently fell back to the live `holidays` library, which reports + Julafton/Nyårsafton/Midsommarafton as ordinary working days. A child + born in 2044 pushes the horizon (their 4th birthday) to 2048 — assert + the resulting `holidays` map still carries all three as real red + days that far out, proving the extended cache (not a silent + fallback) served it, and that the allocator would cost them + correctly rather than charging a needless semesterdag.""" + now = datetime.now() + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Future", created_at=now)) + await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now) + ) + await child_repo.create( + Child( + id=uuid4(), + workspace_id=ws.id, + name="Barn", + birth_date=date(2044, 1, 1), + created_at=now, + ) + ) + + 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)) + + assert alloc_input.horizon_end.year == 2048 + + julafton = alloc_input.holidays.get(date(2048, 12, 24)) + assert julafton is not None and "julafton" in julafton.lower() + + nyarsafton = alloc_input.holidays.get(date(2048, 12, 31)) + assert nyarsafton is not None and "nyårsafton" in nyarsafton.lower() + + midsommarafton = [ + n for d, n in alloc_input.holidays.items() if "midsommarafton" in n.lower() + ] + assert midsommarafton, "Midsommarafton must still be a real free day in 2048" + + async def test_load_raises_validation_error_when_horizon_exceeds_the_cache( + 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: + """A wish with a far-out `last_year` and no children yet (so there's + no age-12 hard cap to save it) can still in principle push the + horizon past even a ~30-year cache. Rather than silently falling + back to the live `holidays` library (the original bug) or silently + clamping the horizon (which would contradict the §6 decision to plan + past B4 and show the squeeze), the loader must raise a + `ValidationError` — the same domain error `CalendarService` uses for + an out-of-range `start_year` — so the router can turn it into a 422 + the UI can explain.""" + now = datetime.now() + ws = await workspace_repo.create(Workspace(id=uuid4(), name="FarFuture", created_at=now)) + await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now) + ) + await leave_wish_repo.create( + LeaveWish( + id=uuid4(), + workspace_id=ws.id, + child_id=None, + name="Sommar", + window_start_month=6, + window_start_day=1, + window_end_month=6, + window_end_day=30, + target_weeks=4, + recurrence="yearly", + first_year=2027, + last_year=2200, + priority=1, + parent_mode="either", + parent_personal_data_uuid=None, + ) + ) + + loader = _make_loader( + workspace_repo, + child_repo, + personal_data_repo, + parental_leave_repo, + vacation_repo, + planned_leave_repo, + leave_wish_repo, + ) + + with pytest.raises(ValidationError): + await loader.load(ws.id, today=date(2027, 1, 1)) diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index 068da5d..cfaf6b1 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -21,6 +21,7 @@ from datetime import date, timedelta from uuid import UUID, uuid4 from app.entities.leave_wish import LeaveWish +from app.entities.planned_leave import PlannedLeaveDay from app.services.allocation_service import ( REASON_DUBBELDAG, REASON_FP_LAGSTA_STRETCH, @@ -152,6 +153,26 @@ def _build_input( ) +def _planned( + *, parent_uuid: UUID, child_id: UUID, on: date, day_type: str = "vacation" +) -> 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. + """ + return PlannedLeaveDay( + id=uuid4(), + workspace_id=uuid4(), + child_id=child_id, + parent_personal_data_uuid=parent_uuid, + date=on, + day_type=day_type, + ) + + FEB_2027 = ((2, 1), (2, 28)) # 28 days, 20 spendable, no real holidays nearby AUG_2027 = ((8, 1), (8, 31)) # 31 days, 22 spendable, no real holidays nearby @@ -1650,3 +1671,224 @@ class TestCase24WindowModeDefaultsToFixed: proposal_explicit = _run(explicit_wish) assert proposal_default == proposal_explicit + + +# --------------------------------------------------------------------------- +# §fix — double-booking on flexible re-propose (2026-09-14 handoff). +# +# A flexible wish-year that already has enough planned days inside its +# bounding range must be treated as funded, proposing nothing new, instead +# of the engine routing a *fresh* candidate search around the applied dates +# and funding the same wish-year a second time on shifted dates. All four +# scenarios reuse TestCase15's exact setup (VAR_2027 bound, weeks=1, Easter +# 2027-03-23..29 as the argmin-cost candidate: 4 free days + 3 spendable +# days 03-23/24/25, all funded via vacation) so the "already funded"/"top +# up"/"still may move" behaviour is verified against a known-good baseline. +# --------------------------------------------------------------------------- + + +class TestCase25FlexibleAlreadyFundedYearProposesNothingNew: + def test_fully_applied_year_adds_zero_new_paid_days(self) -> None: + child = _child(birth_date=date(2020, 1, 1)) + parent = _parent(salary_type="monthly") + wish = _wish( + child_id=child.id, + name="Vårlov", + start=VAR_2027[0], + end=VAR_2027[1], + weeks=1, + first_year=2027, + window_mode="flexible", + ) + # 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)) + for d in (23, 24, 25) + ] + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + planning_goal="balanced", + planning_mode="solo", + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, + vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, + wishes=[wish], + existing_planned=existing, + ) + + proposal = Allocator().propose(alloc_input) + + outcome = proposal.outcomes[0] + assert outcome.strategy != STRATEGY_UNPLACED + assert outcome.shortfall_days == 0 + assert outcome.weeks_funded == outcome.weeks_wished == 1 + # Reused the exact prior window, not a freshly searched one. + assert outcome.start_date == date(2027, 3, 23) + assert outcome.end_date == date(2027, 3, 29) + assert outcome.cost_breakdown["vacation"] == 3 + assert outcome.cost_breakdown["free"] == 4 + + new_paid_days = [d for d in proposal.days if d.wish_id == wish.id and d.day_type != "free"] + assert new_paid_days == [], f"expected zero new paid days, got {new_paid_days}" + + +class TestCase26FlexiblePartialFundingTopsUpRemainder: + def test_partially_applied_year_tops_up_only_the_remainder(self) -> None: + child = _child(birth_date=date(2020, 1, 1)) + parent = _parent(salary_type="monthly") + wish = _wish( + child_id=child.id, + name="Vårlov", + start=VAR_2027[0], + end=VAR_2027[1], + weeks=1, + first_year=2027, + 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))] + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + planning_goal="balanced", + planning_mode="solo", + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, + vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, + wishes=[wish], + existing_planned=existing, + ) + + proposal = Allocator().propose(alloc_input) + + outcome = proposal.outcomes[0] + # Topped up to full funding in the SAME window, not a false shortfall. + assert outcome.shortfall_days == 0 + assert outcome.weeks_funded == outcome.weeks_wished == 1 + assert outcome.start_date == date(2027, 3, 23) + assert outcome.end_date == date(2027, 3, 29) + assert outcome.cost_breakdown["vacation"] == 3 + + new_paid_dates = { + d.date for d in proposal.days if d.wish_id == wish.id and d.day_type != "free" + } + # Exactly the remainder -- never re-proposes the already-applied day, + # never duplicates the whole week. + assert new_paid_dates == {date(2027, 3, 24), date(2027, 3, 25)} + + +class TestCase27FlexibleDeterminismWithExistingPlannedDays: + def test_repeated_runs_with_a_partially_applied_flexible_wish_are_identical(self) -> None: + child = _child(birth_date=date(2020, 1, 1)) + parent = _parent(salary_type="monthly") + wish = _wish( + child_id=child.id, + name="Vårlov", + start=VAR_2027[0], + end=VAR_2027[1], + weeks=1, + first_year=2027, + window_mode="flexible", + ) + existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))] + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + planning_goal="balanced", + planning_mode="solo", + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, + vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, + wishes=[wish], + existing_planned=existing, + ) + + allocator = Allocator() + proposal_1 = allocator.propose(alloc_input) + proposal_2 = allocator.propose(alloc_input) + + assert proposal_1 == proposal_2 + + +class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied: + """§10.3's "still free to move" decision must survive the fix: a + + partially-applied flexible occurrence whose reused window is no longer + available (because a higher-priority fixed wish now claims one of its + still-unfunded days) must NOT be frozen to that window -- it searches + again and lands somewhere collision-free, exactly like an untouched + flexible wish would. + """ + + 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( + child_id=child.id, + name="Fixed", + start=(3, 25), + end=(3, 25), + weeks=1, + first_year=2027, + window_mode="fixed", + ) + flexible = _wish( + child_id=child.id, + name="Vårlov", + start=VAR_2027[0], + end=VAR_2027[1], + weeks=1, + first_year=2027, + window_mode="flexible", + ) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + planning_goal="balanced", + planning_mode="solo", + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, + vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, + wishes=[blocker, flexible], + existing_planned=existing, + ) + + proposal = Allocator().propose(alloc_input) + + by_name = {o.wish_name: o for o in proposal.outcomes} + fixed_outcome = by_name["Fixed"] + flex_outcome = by_name["Vårlov"] + + # Fixed always wins its exact requested date. + assert fixed_outcome.start_date == fixed_outcome.end_date == date(2027, 3, 25) + + # The flexible wish was NOT frozen to the original Easter window + # (03-23..03-29) -- that window is no longer collision-free, so it + # moved rather than silently overlapping the fixed wish's day. + assert (flex_outcome.start_date, flex_outcome.end_date) != ( + date(2027, 3, 23), + date(2027, 3, 29), + ) + assert flex_outcome.strategy != STRATEGY_UNPLACED + assert flex_outcome.shortfall_days == 0 + + flex_dates = { + flex_outcome.start_date + timedelta(days=i) + for i in range((flex_outcome.end_date - flex_outcome.start_date).days + 1) + } + # Still honours the previously-applied day (it's inside the new + # window's span) but never re-claims the fixed wish's date. + assert date(2027, 3, 23) in flex_dates + assert date(2027, 3, 25) not in flex_dates diff --git a/backend/tests/unit/test_swedish_calendar_cache.py b/backend/tests/unit/test_swedish_calendar_cache.py index e55ad41..c4ff101 100644 --- a/backend/tests/unit/test_swedish_calendar_cache.py +++ b/backend/tests/unit/test_swedish_calendar_cache.py @@ -39,14 +39,17 @@ class TestCacheStructure: assert isinstance(calendar_data["years"], list) assert len(calendar_data["years"]) > 0 - def test_cache_covers_10_years(self, calendar_data: dict[str, Any]) -> None: - """Cache must contain exactly 10 years.""" - assert len(calendar_data["years"]) == 10 + def test_cache_covers_30_years(self, calendar_data: dict[str, Any]) -> None: + """Cache must contain exactly 30 years (extended from the original + 10 so the planning engine's horizon can never realistically run past + the cache — see `AllocationInputLoader.load`'s `strict=True` switch + in `app.services.allocation_service`).""" + assert len(calendar_data["years"]) == 30 def test_cache_year_range(self, calendar_data: dict[str, Any]) -> None: - """Cache must cover current year + 9 more (10 total).""" + """Cache must cover current year + 29 more (30 total).""" years = [y["year"] for y in calendar_data["years"]] - assert years == list(range(2026, 2036)), f"Expected [2026..2035], got {years}" + assert years == list(range(2026, 2056)), f"Expected [2026..2055], got {years}" def test_each_year_has_days(self, calendar_data: dict[str, Any]) -> None: """Each year must have a 'days' list.""" @@ -399,3 +402,127 @@ class TestCacheConsistency: with open(CACHE_FILE, "rb") as f: content = f.read() assert content.endswith(b"\n"), "Cache file must end with newline" + + +class TestRedDayCountConsistency: + """No year in the extended range may be silently poorer than its + neighbours — this is exactly the shape of the bug this cache extension + fixes (years past the old cache lost Julafton/Nyårsafton/Midsommarafton + to the `holidays`-library fallback, 13 red days instead of 18).""" + + def test_red_day_count_consistent_with_one_documented_exception( + self, calendar_data: dict[str, Any] + ) -> None: + """Every year has 18 red (public + de_facto) dates, EXCEPT a year + where Pingstdagen (Easter + 49 days) coincides with Sveriges + nationaldag (fixed June 6) — then the two holidays share a single + calendar date, giving 17 distinct dates. That happens for 2049 and + 2055 in this cache. Verified by name (not just assumed from the + count), so a genuinely degraded year can't hide behind it. + """ + for year_data in calendar_data["years"]: + year = year_data["year"] + red_days = [d for d in year_data["days"] if d["category"] in ("public", "de_facto")] + june_6 = next( + (d for d in red_days if d["date"] == f"{year:04d}-06-06"), + None, + ) + pingst_meets_nationaldag = june_6 is not None and ( + "pingstdagen" in june_6["name_sv"] and "nationaldag" in june_6["name_sv"] + ) + expected = 17 if pingst_meets_nationaldag else 18 + assert len(red_days) == expected, ( + f"{year}: expected {expected} red dates, got {len(red_days)} " + f"({sorted(d['date'] for d in red_days)})" + ) + + def test_the_known_coincidence_years_are_exactly_2049_and_2055( + self, calendar_data: dict[str, Any] + ) -> None: + """Pin down which years hit the 17-count exception, so a future cache + regeneration that shifts the covered range doesn't silently change + this test's assumptions without notice.""" + coincidence_years = set() + for year_data in calendar_data["years"]: + year = year_data["year"] + for day in year_data["days"]: + if ( + day["date"] == f"{year:04d}-06-06" + and "pingstdagen" in day["name_sv"] + and "nationaldag" in day["name_sv"] + ): + coincidence_years.add(year) + assert coincidence_years == {2049, 2055} + + +class TestMultiObservanceNamesArePreserved: + """A date can carry more than one `observances` entry from the Dagsmart + API when two distinct holidays coincide (see `normalize_day` in + `scripts/fetch_swedish_calendar.py`). Taking only the first observance + would silently drop the second holiday's name — verify both survive.""" + + def test_pingstdagen_and_nationaldagen_both_named_in_2049( + self, calendar_data: dict[str, Any] + ) -> None: + cache_year = next(y for y in calendar_data["years"] if y["year"] == 2049) + day = next(d for d in cache_year["days"] if d["date"] == "2049-06-06") + assert "pingstdagen" in day["name_sv"] + assert "nationaldag" in day["name_sv"] + assert day["category"] == "public" + assert day["working_day"] is False + + +class TestEasterAcrossTheExtendedRange: + """Easter moves every year (computed, not fixed) — sample a few years + spread across the new 30-year range to confirm Långfredagen/Påskdagen/ + Annandag påsk keep landing on the correct relative weekdays this far out, + not just near the start of the cache.""" + + @pytest.mark.parametrize("year", [2026, 2038, 2049, 2055]) + def test_easter_triduum_lands_correctly(self, calendar_data: dict[str, Any], year: int) -> None: + cache_year = next(y for y in calendar_data["years"] if y["year"] == year) + by_date = {d["date"]: d for d in cache_year["days"]} + + good_friday = next(d for d, info in by_date.items() if "långfredagen" in info["name_sv"]) + easter_sunday = next(d for d, info in by_date.items() if "påskdagen" in info["name_sv"]) + easter_monday = next(d for d, info in by_date.items() if "annandag påsk" in info["name_sv"]) + + gf = datetime.date.fromisoformat(good_friday) + es = datetime.date.fromisoformat(easter_sunday) + em = datetime.date.fromisoformat(easter_monday) + + assert gf.weekday() == 4, f"{year}: Långfredagen must be a Friday" + assert es.weekday() == 6, f"{year}: Påskdagen must be a Sunday" + assert em.weekday() == 0, f"{year}: Annandag påsk must be a Monday" + assert es - gf == datetime.timedelta(days=2), ( + f"{year}: Good Friday must be 2 days before Easter Sunday" + ) + assert em - es == datetime.timedelta(days=1), ( + f"{year}: Easter Monday must be the day after Easter Sunday" + ) + + +class TestValborgIsNotADefaultDayOff: + """§6 product decision: Valborgsmässoafton (30 April) is deliberately NOT + a statutory/de-facto day off — it was added, then removed, because most + Swedes work on it. Confirm the extended cache still treats it as an + ordinary day (or, when it happens to be a genuine klämdag, that it's + filed as `bridge_day` — still `working_day: true` — never `public`/ + `de_facto`).""" + + def test_valborg_never_public_or_de_facto_in_any_cached_year( + self, calendar_data: dict[str, Any] + ) -> None: + for year_data in calendar_data["years"]: + year = year_data["year"] + valborg = next( + (d for d in year_data["days"] if d["date"] == f"{year:04d}-04-30"), + None, + ) + if valborg is None: + continue # not even a bridge day this year — also fine + assert valborg["category"] != "public", f"{year}: Valborg wrongly marked public" + assert valborg["category"] != "de_facto", f"{year}: Valborg wrongly marked de_facto" + assert valborg["working_day"] is True, ( + f"{year}: Valborg must remain an ordinary working day" + )