76b1e712b9
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
197 lines
7.3 KiB
Python
197 lines
7.3 KiB
Python
"""Swedish calendar utilities for leave planning.
|
|
|
|
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 + de facto days + Sundays for a year.
|
|
|
|
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", "Julafton")
|
|
"""
|
|
if year in _holiday_cache:
|
|
return _holiday_cache[year]
|
|
|
|
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, de facto day, or Sunday."""
|
|
holidays_for_year = get_swedish_holidays(date.year)
|
|
return date in holidays_for_year
|
|
|
|
|
|
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)
|