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:
@@ -26,7 +26,24 @@ class InvalidDayTypeError(ValidationError):
|
||||
|
||||
|
||||
class DubbeldagError(ValidationError):
|
||||
"""Raised when a dubbeldag breaks the 15-month window or the 60-day quota."""
|
||||
"""Raised when a dubbeldag breaks the 15-month window or the 60-day quota.
|
||||
|
||||
Carries an optional `message_key` + `data` pair (§3.5) alongside the
|
||||
human-readable `message` (kept for anything that only logs/prints the
|
||||
exception) so callers that need to surface this to the UI — see
|
||||
`PlanningService._validate_dubbeldagar_batch` — can build an i18n-ready
|
||||
`Rejection` instead of leaking this English string past the backend.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
message_key: str = "",
|
||||
data: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message_key = message_key
|
||||
self.data: dict[str, object] = data if data is not None else {}
|
||||
|
||||
|
||||
class AuthError(DomainError):
|
||||
|
||||
+160
-17
@@ -1,48 +1,148 @@
|
||||
"""Swedish public holiday utilities for leave planning.
|
||||
"""Swedish calendar utilities for leave planning.
|
||||
|
||||
Uses the holidays library to get röda dagar (red days) in Sweden.
|
||||
Results are cached per year to avoid repeated parsing.
|
||||
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
|
||||
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.
|
||||
|
||||
Critical trap (see AGENTS.md): the `holidays` library returns *combined*
|
||||
names when a fixed holiday lands on a Sunday, e.g. Easter Sunday is the
|
||||
single string "Påskdagen; Söndag". Filtering by `"Söndag" in name` would
|
||||
silently delete Påskdagen, Pingstdagen, Nationaldagen and Annandag jul
|
||||
whenever they fall on a Sunday. Always match the *exact* string "Söndag".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
import holidays
|
||||
|
||||
_CACHE_PATH = Path(__file__).resolve().parent.parent / "data" / "swedish_calendar.json"
|
||||
|
||||
# The generic "just a Sunday" marker. Exact-match only — see module docstring.
|
||||
# Public so other modules (e.g. allocation_service) share this single
|
||||
# constant instead of re-declaring the literal and risking drift.
|
||||
SUNDAY_HOLIDAY_NAME = "Söndag"
|
||||
_SUNDAY_HOLIDAY_NAME = SUNDAY_HOLIDAY_NAME
|
||||
|
||||
# Calendar-cache categories that represent a day off. `bridge_day` (klämdag)
|
||||
# is deliberately excluded: those are `working_day: true` — cheap days to buy
|
||||
# with a vacation day, not free days.
|
||||
_FREE_CATEGORIES = {"public", "de_facto"}
|
||||
|
||||
_holiday_cache: Dict[int, Dict[datetime.date, str]] = {}
|
||||
_raw_cache_by_year: Dict[int, list] | None = None
|
||||
|
||||
|
||||
def _load_raw_cache() -> Dict[int, list]:
|
||||
"""Lazily load & index `swedish_calendar.json` by year. Empty dict if the
|
||||
cache file is missing (all years then fall back to the `holidays` lib)."""
|
||||
global _raw_cache_by_year
|
||||
if _raw_cache_by_year is None:
|
||||
if _CACHE_PATH.exists():
|
||||
with open(_CACHE_PATH, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
_raw_cache_by_year = {
|
||||
year_data["year"]: year_data["days"] for year_data in data["years"]
|
||||
}
|
||||
else:
|
||||
_raw_cache_by_year = {}
|
||||
return _raw_cache_by_year
|
||||
|
||||
|
||||
def _from_cache(year: int) -> Dict[datetime.date, str] | None:
|
||||
"""Build the date->name map for `year` from the JSON cache.
|
||||
|
||||
Returns None if `year` isn't in the cached range (caller should fall
|
||||
back to the `holidays` library).
|
||||
|
||||
Every Sunday is seeded with the bare "Söndag" marker (the cache itself
|
||||
only lists *special* dates, not every ordinary Sunday) so downstream
|
||||
free-day logic that predates this cache keeps working unchanged. A
|
||||
public/de_facto entry that coincides with a Sunday is combined into
|
||||
"<Name>; Söndag", mirroring the `holidays` library's own convention.
|
||||
"""
|
||||
days = _load_raw_cache().get(year)
|
||||
if days is None:
|
||||
return None
|
||||
|
||||
result: Dict[datetime.date, str] = {}
|
||||
|
||||
day = datetime.date(year, 1, 1)
|
||||
one_day = datetime.timedelta(days=1)
|
||||
while day.year == year:
|
||||
if day.weekday() == 6: # Sunday
|
||||
result[day] = _SUNDAY_HOLIDAY_NAME
|
||||
day += one_day
|
||||
|
||||
for entry in days:
|
||||
if entry["category"] not in _FREE_CATEGORIES:
|
||||
continue
|
||||
entry_date = datetime.date.fromisoformat(entry["date"])
|
||||
name = entry["name_sv"].capitalize()
|
||||
if entry_date in result: # coincides with the bare Sunday seeded above
|
||||
name = f"{name}; {_SUNDAY_HOLIDAY_NAME}"
|
||||
result[entry_date] = name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _from_library(year: int) -> Dict[datetime.date, str]:
|
||||
"""Fallback for years outside the cached range: the `holidays` package.
|
||||
|
||||
Only knows formal röda dagar + every Sunday (the library's own
|
||||
Sweden-specific quirk) — no de facto days, no klämdagar.
|
||||
"""
|
||||
se_holidays = holidays.Sweden(years=year, language="sv")
|
||||
return dict(sorted(se_holidays.items()))
|
||||
|
||||
|
||||
def get_swedish_holidays(year: int) -> Dict[datetime.date, str]:
|
||||
"""Get all Swedish public holidays (röda dagar) for a given year.
|
||||
"""Get all Swedish public holidays + de facto days + Sundays for a year.
|
||||
|
||||
Returns a mapping of date -> holiday name in Swedish.
|
||||
|
||||
The result is cached per year; subsequent calls with the same year
|
||||
return the cached value without re-parsing.
|
||||
Returns a mapping of date -> Swedish display name. Cached per year;
|
||||
subsequent calls with the same year return the same object.
|
||||
|
||||
Args:
|
||||
year: Calendar year (e.g., 2026)
|
||||
|
||||
Returns:
|
||||
Dict mapping datetime.date to holiday name (e.g. "Juldagen", "Midsommar")
|
||||
Dict mapping datetime.date to holiday name (e.g. "Juldagen", "Julafton")
|
||||
"""
|
||||
if year in _holiday_cache:
|
||||
return _holiday_cache[year]
|
||||
|
||||
# Get Swedish holidays for the year with Swedish-language names.
|
||||
se_holidays = holidays.Sweden(years=year, language="sv")
|
||||
|
||||
# Convert to our format: date -> name
|
||||
result: Dict[datetime.date, str] = {}
|
||||
for holiday_date, holiday_name in sorted(se_holidays.items()):
|
||||
result[holiday_date] = holiday_name
|
||||
result = _from_cache(year)
|
||||
if result is None:
|
||||
result = _from_library(year)
|
||||
|
||||
_holiday_cache[year] = result
|
||||
return result
|
||||
|
||||
|
||||
def get_red_days(year: int) -> Dict[datetime.date, str]:
|
||||
"""Real red days for `year`: public holidays and de facto days off,
|
||||
EXCLUDING ordinary Sundays (the bare "Söndag" marker).
|
||||
|
||||
Matches the exact string only — "Påskdagen; Söndag" (a holiday that
|
||||
happens to fall on a Sunday) is kept; only a standalone "Söndag" is
|
||||
dropped. Use this (not `get_swedish_holidays`) for red-day cluster
|
||||
detection, where a bare weekend must not be conflated with a real
|
||||
holiday.
|
||||
"""
|
||||
return {
|
||||
d: name for d, name in get_swedish_holidays(year).items() if name != _SUNDAY_HOLIDAY_NAME
|
||||
}
|
||||
|
||||
|
||||
def is_swedish_holiday(date: datetime.date) -> bool:
|
||||
"""Check if a date is a Swedish public holiday."""
|
||||
"""Check if a date is a Swedish public holiday, de facto day, or Sunday."""
|
||||
holidays_for_year = get_swedish_holidays(date.year)
|
||||
return date in holidays_for_year
|
||||
|
||||
@@ -51,3 +151,46 @@ def get_holiday_name(date: datetime.date) -> str | None:
|
||||
"""Get the name of a Swedish public holiday, or None if not a holiday."""
|
||||
holidays_for_year = get_swedish_holidays(date.year)
|
||||
return holidays_for_year.get(date)
|
||||
|
||||
|
||||
def is_non_working_day(date: datetime.date) -> bool:
|
||||
"""True if `date` is a weekend day, a public holiday, or a de facto day
|
||||
off (julafton, midsommarafton, nyårsafton, ...).
|
||||
|
||||
This is the single source of truth for "is this an ordinary free day"
|
||||
used by the planning engine and the tips engine. It does NOT know about
|
||||
per-workspace `DayOverride`s — those are applied on top by the caller
|
||||
(see `app.services.allocation_service.is_free_day`).
|
||||
"""
|
||||
if date.weekday() >= 5:
|
||||
return True
|
||||
return is_swedish_holiday(date)
|
||||
|
||||
|
||||
def cached_year_bounds() -> tuple[int, int] | None:
|
||||
"""(min_year, max_year) covered by `swedish_calendar.json`, or None if the
|
||||
cache file is missing.
|
||||
|
||||
Used by `app.services.calendar_service` to validate/paginate the
|
||||
calendar-days endpoint window without hardcoding the 10-year range.
|
||||
"""
|
||||
years = _load_raw_cache()
|
||||
if not years:
|
||||
return None
|
||||
return min(years), max(years)
|
||||
|
||||
|
||||
def is_klamdag(date: datetime.date) -> bool:
|
||||
"""True if `date` is a klämdag: an ordinary working day sandwiched
|
||||
between two non-working days (e.g. a Friday between a Thursday public
|
||||
holiday and the weekend).
|
||||
|
||||
Verified against `swedish_calendar.json`'s `bridge_day` category: every
|
||||
date flagged `bridge_day` satisfies this rule, and nothing else does.
|
||||
Computed generically (not read from the cache) so it also works for
|
||||
years outside the cached range.
|
||||
"""
|
||||
if is_non_working_day(date):
|
||||
return False
|
||||
one_day = datetime.timedelta(days=1)
|
||||
return is_non_working_day(date - one_day) and is_non_working_day(date + one_day)
|
||||
|
||||
@@ -9,13 +9,13 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import holidays
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from app.config import settings
|
||||
from app.entities.child import Child
|
||||
from app.entities.personal_data import PersonalData
|
||||
from app.entities.planned_leave import PlannedLeaveDay
|
||||
from app.utils.holidays import is_non_working_day
|
||||
|
||||
|
||||
def weekly_intensity(planned_days: list[PlannedLeaveDay], period_weeks: float) -> float:
|
||||
@@ -90,21 +90,23 @@ def sgi_at_risk(
|
||||
def red_day_clusters_in_range(
|
||||
from_date: date, to_date: date, year: int | None = None
|
||||
) -> list[tuple[date, date]]:
|
||||
"""Return (start, end) pairs for clusters of ≥2 consecutive public holidays.
|
||||
"""Return (start, end) pairs for clusters of ≥2 consecutive non-working days.
|
||||
|
||||
Uses the `holidays` library (Sweden locale). A "cluster" is any stretch
|
||||
where consecutive days are either weekends or public holidays. Useful for
|
||||
the red-day wrapping tip.
|
||||
Uses `app.utils.holidays.is_non_working_day` — the single source of truth
|
||||
for "is this day free" (calendar cache + `holidays` library fallback),
|
||||
so weekends, public holidays AND de facto days (julafton, midsommarafton,
|
||||
nyårsafton, ...) all count. `year` is accepted for backward compatibility
|
||||
but unused: `is_non_working_day` resolves each date's own year, so a
|
||||
range spanning a year boundary is handled correctly without it.
|
||||
"""
|
||||
target_year = year or from_date.year
|
||||
se_holidays = holidays.Sweden(years=target_year)
|
||||
del year # unused — is_non_working_day resolves per-date
|
||||
|
||||
clusters: list[tuple[date, date]] = []
|
||||
cluster_start: date | None = None
|
||||
|
||||
current = from_date
|
||||
while current <= to_date:
|
||||
is_holiday = current in se_holidays or current.weekday() >= 5
|
||||
is_holiday = is_non_working_day(current)
|
||||
if is_holiday:
|
||||
if cluster_start is None:
|
||||
cluster_start = current
|
||||
|
||||
Reference in New Issue
Block a user