28c632ca36
Found by probing the running API; both suites were green and missed it. CalendarService validated only start_year against the cache, never the window end. GET /api/calendar/days?start_year=2035&years=4 returned 200 with end_year=2038, pulling 2036-2038 from the live holidays-library fallback in app/utils/holidays.py. That fallback does not know the de facto free days, so Julafton, Nyarsafton and Midsommarafton were reported as ordinary working days (18 red days per cached year vs 13 per uncached year). Julafton being charged as a working day was fixed once before; it had reappeared past 2035. - clamp end_year to max_year; the response now always reports what it returned - add strict=True to get_swedish_holidays/get_red_days plus UncachedYearError, checked before the memo cache, so a strict caller fails loudly rather than silently degrading. Non-strict default behaviour is unchanged. - RedDaysList derived endYear locally as startYear + years - 1, independent of the response, so a clamped window rendered phantom empty years. It now takes endYear from the backend. Tests: clamping at and before max_year (service + router), Julafton/Nyarsafton/ Midsommarafton red for every cached year, red-day count consistent across the served range, and strict-mode raise/pass-through. Backend 404 -> 414 tests, frontend 704 -> 705. Known, deliberately not fixed here: AllocationInputLoader builds its holiday map the same non-strict way over the planning horizon, so it has the same exposure once the horizon passes 2035. Clamping would silently truncate planning and raising would break real plans, so it needs a product decision first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
230 lines
8.9 KiB
Python
230 lines
8.9 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()))
|
|
|
|
|
|
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).
|
|
"""
|
|
|
|
|
|
def get_swedish_holidays(year: int, *, strict: bool = False) -> 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)
|
|
strict: if True, raise `UncachedYearError` instead of silently
|
|
falling back to the `holidays` library when `year` is outside
|
|
the cached range. Checked before consulting the per-year cache
|
|
so a prior non-strict call for the same (uncached) year can
|
|
never mask a later strict call's check.
|
|
|
|
Returns:
|
|
Dict mapping datetime.date to holiday name (e.g. "Juldagen", "Julafton")
|
|
"""
|
|
if strict:
|
|
bounds = cached_year_bounds()
|
|
if bounds is None or not (bounds[0] <= year <= bounds[1]):
|
|
where = f"range {bounds[0]}-{bounds[1]}" if bounds is not None else "cache (missing)"
|
|
raise UncachedYearError(
|
|
f"Year {year} is outside the cached Swedish calendar {where}; "
|
|
"refusing to fall back to the live `holidays` library for a strict caller."
|
|
)
|
|
|
|
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, *, strict: bool = False) -> 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.
|
|
|
|
`strict` is forwarded to `get_swedish_holidays` — see there.
|
|
"""
|
|
return {
|
|
d: name
|
|
for d, name in get_swedish_holidays(year, strict=strict).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)
|