Files
F-r-ldraledigplannerare/backend/app/services/calendar_service.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

164 lines
6.3 KiB
Python

"""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),
)