"""Business logic for the Swedish calendar endpoint (röda dagar / klämdagar). One service serves two UI surfaces (§3.4 shading the Kalender page, §3.8 the per-year red-days list in Settings) from the same source data: the `swedish_calendar.json` cache exposed via `app.utils.holidays`, merged with the current workspace's `DayOverride`s (per §3.8's product decision) so the UI can show what the app actually treats as free, not just the statutory calendar. No runtime external API calls (§6): everything here reads the pre-built cache via `app.utils.holidays`. The requested window is clamped to the cache's [min_year, max_year] (see `get_calendar_window`) and every lookup uses `strict=True`, so this endpoint can never silently fall back to the local `holidays` library for a year the cache doesn't cover — that fallback only knows formal röda dagar (+ every Sunday), not de facto days or klämdagar, and serving it unlabelled here previously caused a real bug (Julafton/Nyårsafton/Midsommarafton silently reading as working days past the cache's end). """ from __future__ import annotations from dataclasses import dataclass from datetime import date, timedelta from typing import Literal from uuid import UUID from app.services.allocation_service import is_free_day from app.services.day_override_service import DayOverrideService from app.utils.errors import ValidationError from app.utils.holidays import ( cached_year_bounds, get_red_days, get_swedish_holidays, is_klamdag, ) # Fixed page size per §3.8's product decision: paginate four years ahead at a # time even though the full ~30-year cache is available. MAX_WINDOW_YEARS = 4 DayType = Literal["red_day", "klamdag", "weekend", "working_day"] @dataclass class CalendarDay: """One day's calendar metadata, statutory + workspace override. `day_type` reuses the existing vocabulary from `app.utils.holidays` / `swedish_calendar.json` — no parallel taxonomy: a "red_day" is exactly what `get_red_days` returns (public/de_facto, exact-name matched so a holiday landing on a Sunday is never conflated with a bare weekend, see the module docstring on `app.utils.holidays`), "klamdag" mirrors the cache's `bridge_day` category (verified generic via `is_klamdag`), and "weekend"/"working_day" are the remaining ordinary cases. """ date: date name: str | None day_type: DayType is_override: bool override_is_working_day: bool | None is_free: bool @dataclass class CalendarWindow: start_year: int end_year: int min_year: int | None max_year: int | None days: list[CalendarDay] class CalendarService: def __init__(self, day_override_service: DayOverrideService) -> None: self._day_override_service = day_override_service async def get_calendar_window( self, workspace_id: UUID, start_year: int, years: int = MAX_WINDOW_YEARS ) -> CalendarWindow: """Return calendar-day metadata for `years` years starting at `start_year` (capped at `MAX_WINDOW_YEARS`), merged with the workspace's day overrides. Raises `ValidationError` for an out-of-range `years` or a `start_year` outside the cached year window (when the cache file is present at all). """ if years < 1 or years > MAX_WINDOW_YEARS: raise ValidationError(f"years must be between 1 and {MAX_WINDOW_YEARS} (got {years})") bounds = cached_year_bounds() min_year, max_year = bounds if bounds is not None else (None, None) if min_year is not None and max_year is not None: if start_year < min_year or start_year > max_year: raise ValidationError( f"start_year must be between {min_year} and {max_year} (got {start_year})" ) end_year = start_year + years - 1 if max_year is not None and end_year > max_year: # Clamp rather than 422: the response already carries min_year/ # max_year for the UI's paging bounds, so the last page is # naturally shorter than `years` requested. `end_year` below # reports the ACTUAL clamped range — never a range not returned. end_year = max_year overrides = await self._day_override_service.get_effective_map(workspace_id) days: list[CalendarDay] = [] for year in range(start_year, end_year + 1): # `strict=True`: after the clamp above, every `year` here is # guaranteed inside [min_year, max_year], so this must never hit # the live-`holidays` fallback. If it ever does, that's a bug in # the clamping above, not bad input — fail loudly (see # `app.utils.holidays.UncachedYearError`) rather than silently # serving degraded data for this endpoint (the actual bug this # guards against: years past the cache losing Julafton, # Nyårsafton, Midsommarafton). holidays_map = get_swedish_holidays(year, strict=True) red_days = get_red_days(year, strict=True) day = date(year, 1, 1) one_day = timedelta(days=1) while day.year == year: days.append(self._build_day(day, holidays_map, red_days, overrides)) day += one_day return CalendarWindow( start_year=start_year, end_year=end_year, min_year=min_year, max_year=max_year, days=days, ) @staticmethod def _build_day( day: date, holidays_map: dict[date, str], red_days: dict[date, str], overrides: dict[date, bool], ) -> CalendarDay: if day in red_days: day_type: DayType = "red_day" elif is_klamdag(day): day_type = "klamdag" elif day.weekday() >= 5: day_type = "weekend" else: day_type = "working_day" is_override = day in overrides override_is_working_day = overrides.get(day) if is_override else None return CalendarDay( date=day, name=red_days.get(day) if day_type == "red_day" else None, day_type=day_type, is_override=is_override, override_is_working_day=override_is_working_day, is_free=is_free_day(day, holidays_map, overrides), )