Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes

Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 02:33:03 +02:00
parent 3ca1418d94
commit 76b1e712b9
218 changed files with 29867 additions and 6085 deletions
+124
View File
@@ -5,7 +5,10 @@ import datetime
from app.utils.holidays import (
get_holiday_name,
get_red_days,
get_swedish_holidays,
is_klamdag,
is_non_working_day,
is_swedish_holiday,
)
@@ -131,3 +134,124 @@ class TestGetHolidayName:
name = get_holiday_name(datetime.date(2026, 6, 20))
assert name is not None
assert "Midsommar" in name
# ---------------------------------------------------------------------------
# De facto days (julafton, midsommarafton, nyårsafton) — cached-year source
# ---------------------------------------------------------------------------
class TestDeFactoDaysAreNonWorking:
"""The calendar cache adds de facto days the `holidays` library doesn't
know about — these must count as free, closing the bug where the
allocator would spend a vacation day on julafton."""
def test_julafton_is_non_working(self) -> None:
assert is_non_working_day(datetime.date(2027, 12, 24))
assert is_swedish_holiday(datetime.date(2027, 12, 24))
name = get_holiday_name(datetime.date(2027, 12, 24))
assert name is not None
assert "Julafton" in name
def test_nyarsafton_is_non_working(self) -> None:
assert is_non_working_day(datetime.date(2027, 12, 31))
name = get_holiday_name(datetime.date(2027, 12, 31))
assert name is not None
assert "Nyårsafton" in name
def test_midsommarafton_is_non_working(self) -> None:
# Midsommardagen 2027 is Sat 2027-06-26 -> midsommarafton is Fri 6/25.
assert is_non_working_day(datetime.date(2027, 6, 25))
name = get_holiday_name(datetime.date(2027, 6, 25))
assert name is not None
assert "Midsommarafton" in name
def test_ordinary_weekday_is_still_working(self) -> None:
assert not is_non_working_day(datetime.date(2027, 3, 2)) # a plain Tuesday
# ---------------------------------------------------------------------------
# Klämdagar
# ---------------------------------------------------------------------------
class TestKlamdag:
def test_known_klamdag_2027_is_detected(self) -> None:
"""2027-05-06 (Thu) is Kristi himmelsfärdsdag; 2027-05-08 (Sat) is the
weekend; 2027-05-07 (Fri) is squeezed between them."""
assert is_klamdag(datetime.date(2027, 5, 7))
def test_julafton_itself_is_not_a_klamdag(self) -> None:
"""Julafton is a free day in its own right, not a working day
squeezed between two others."""
assert not is_klamdag(datetime.date(2027, 12, 24))
def test_ordinary_weekday_is_not_a_klamdag(self) -> None:
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))
# ---------------------------------------------------------------------------
# get_red_days — real red days, excluding ordinary Sundays
# ---------------------------------------------------------------------------
class TestGetRedDays:
def test_bare_sunday_excluded(self) -> None:
"""2027-01-03 is a plain Sunday with no holiday -> excluded."""
red_days = get_red_days(2027)
assert datetime.date(2027, 1, 3) not in red_days
def test_easter_sunday_survives_the_filter(self) -> None:
"""Påskdagen 2027 (2027-03-28) IS a Sunday, but must survive: the
filter matches the exact string "Söndag" only, not `"Söndag" in name`.
A substring filter would silently delete this (and Pingstdagen,
Nationaldagen, Annandag jul whenever they land on a Sunday)."""
red_days = get_red_days(2027)
easter_sunday = datetime.date(2027, 3, 28)
assert easter_sunday in red_days
assert "Påskdagen" in red_days[easter_sunday]
def test_julafton_included_as_a_real_red_day(self) -> None:
red_days = get_red_days(2027)
assert datetime.date(2027, 12, 24) in red_days
def test_only_bare_sundays_are_removed(self) -> None:
all_holidays = get_swedish_holidays(2027)
red_days = get_red_days(2027)
removed = set(all_holidays) - set(red_days)
assert all(all_holidays[d] == "Söndag" for d in removed)
# ---------------------------------------------------------------------------
# Fallback path for years outside the cached range (2026-2035)
# ---------------------------------------------------------------------------
class TestFallbackForUncachedYear:
def test_uncached_year_still_returns_formal_holidays(self) -> None:
"""2040 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)
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))
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)`.
easter_sunday = next(
d for d, name in get_swedish_holidays(2040).items() if "Påskdagen" in name
)
assert easter_sunday in red_days