249e277696
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
233 lines
9.1 KiB
Python
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)
|