76b1e712b9
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
144 lines
4.9 KiB
Python
144 lines
4.9 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 (or, outside its range, the local `holidays` library) via
|
|
`app.utils.holidays`.
|
|
"""
|
|
|
|
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 10-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 10-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
|
|
|
|
overrides = await self._day_override_service.get_effective_map(workspace_id)
|
|
|
|
days: list[CalendarDay] = []
|
|
for year in range(start_year, end_year + 1):
|
|
holidays_map = get_swedish_holidays(year)
|
|
red_days = get_red_days(year)
|
|
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),
|
|
)
|