Files
F-r-ldraledigplannerare/backend/app/utils/holidays.py
T
jacobh2 249e277696 Fix flexible-wish double-booking, extend calendar cache to 2055, add journey tests
Second real bug found by driving the running app while the suites were green.

Flexible wishes were funded twice across propose -> apply -> re-propose. Because
planned_leave_day carries no wish_id, the engine could not tell that applied days
were that wish's own prior occurrence; it treated them as occupied space, routed
the candidate search around them, and funded the year again on shifted dates while
reporting weeks_funded 1.0. Applying twice turned "one week each spring" into 18
paid days instead of 9. Fixed wishes were unaffected and stay unaffected.

Fix (allocation_service.py), per the approved approach of counting already-applied
days inside the bounding range rather than adding a wish_id column:
- _Ledger._persisted_lookup snapshots existing days BEFORE this run folds in its
  own freshly-computed windows, so a fixed wish's new placement is never mistaken
  for a flexible wish's history
- _reuse_already_funded_window finds this wish's persisted dates within its bound
  and reuses the window containing them, via the ordinary fund_window path
- full funding therefore yields zero new days and partial funding tops up exactly
  the remainder, with no duplication and no false shortfall
- with no persisted dates, or if the reused window is no longer collision-free, it
  falls through to the original search, so an unsatisfied year is never frozen

Calendar cache extended 2026-2035 -> 2026-2055 (39KB -> 116KB) and the allocator
switched to strict, so an uncached year raises UncachedYearError -> ValidationError
-> HTTP 422 instead of silently treating Julafton, Nyarsafton and Midsommarafton as
working days. Also fixed normalize_day dropping the second name when two holidays
share a date (Nationaldagen was being lost). 2049 and 2055 legitimately have 17 red
days, not 18: Pingstdagen coincides with Nationaldagen.

New backend/tests/integration/test_user_journey.py drives the real app over HTTP
across seven journeys: the double-booking guard, the fixed-wish control, apply
idempotency, fixed-never-displaced-by-flexible, flexible wishes tracking Easter
per year, age-4 arithmetic self-consistency, and a recursive check that no Swedish
text reaches the wire now that everything is message_key + data.

New backend/scripts/seed_demo.py seeds a realistic workspace through the real
service layer for manual testing: demo@example.com / DemoPass123!

Backend 414 -> 435 tests. Frontend unchanged at 705.

Still not verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 07:38:07 +02:00

233 lines
9.1 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 ~30-year cache (2026-2055) 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: `CalendarService` clamps its
requested window to `cached_year_bounds()` first, and the planning
engine's `AllocationInputLoader` surfaces this as a `ValidationError`
instead of letting it escape as a raw `RuntimeError` — see
`app.services.allocation_service.AllocationInputLoader.load`. Hitting
this in either caller is therefore a bug in that guard, 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 cached-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)