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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user