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
164 lines
6.3 KiB
Python
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),
|
|
)
|