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
326 lines
11 KiB
Python
Executable File
326 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Fetch and cache Swedish calendar data (holidays, de facto days).
|
|
|
|
This script fetches Swedish holiday data from the Dagsmart API
|
|
(https://api.dagsmart.se/) and caches it locally as JSON.
|
|
|
|
The cache is used by the planning engine to determine working days
|
|
without making API calls at runtime, ensuring the planner is offline
|
|
and deterministic.
|
|
|
|
Rate limiting: 5 requests per 10 seconds (enforced via time.sleep).
|
|
"""
|
|
|
|
import datetime
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
|
|
# Configuration
|
|
DAGSMART_API_BASE = "https://api.dagsmart.se/v3/holidays"
|
|
CURRENT_YEAR = datetime.date.today().year
|
|
YEARS_TO_FETCH = 30 # Fetch 30 years forward from current year (inclusive).
|
|
# Bump this constant (and rerun this script) whenever the planning horizon
|
|
# risks approaching the end of the cached range; see AllocationInputLoader's
|
|
# strict=True check in app/services/allocation_service.py.
|
|
RATE_LIMIT_DELAY = 2.5 # 5 requests per 10 seconds; conservative margin
|
|
OUTPUT_DIR = Path(__file__).parent.parent / "app" / "data"
|
|
OUTPUT_FILE = OUTPUT_DIR / "swedish_calendar.json"
|
|
|
|
|
|
def validate_api_response(data: dict[str, Any]) -> None:
|
|
"""Validate that API response has expected structure."""
|
|
if "year" not in data:
|
|
raise ValueError("API response missing 'year' field")
|
|
if "days" not in data:
|
|
raise ValueError("API response missing 'days' field")
|
|
if not isinstance(data["days"], list):
|
|
raise ValueError("API 'days' field must be a list")
|
|
|
|
# Validate each day record
|
|
for day in data["days"]:
|
|
if "date" not in day:
|
|
raise ValueError("API day record missing 'date'")
|
|
if "holidayType" not in day:
|
|
raise ValueError("API day record missing 'holidayType'")
|
|
if "workingDay" not in day:
|
|
raise ValueError("API day record missing 'workingDay'")
|
|
if "observances" not in day:
|
|
raise ValueError("API day record missing 'observances'")
|
|
if not isinstance(day["observances"], list):
|
|
raise ValueError("API day 'observances' must be a list")
|
|
|
|
|
|
def fetch_year(year: int, max_retries: int = 5) -> dict[str, Any]:
|
|
"""Fetch calendar data for a single year from Dagsmart API.
|
|
|
|
Implements exponential backoff retry for rate limiting (429 responses).
|
|
|
|
Args:
|
|
year: Calendar year to fetch
|
|
max_retries: Maximum number of retry attempts
|
|
|
|
Returns:
|
|
Parsed JSON response from API
|
|
|
|
Raises:
|
|
httpx.HTTPError: On network or HTTP errors
|
|
ValueError: On invalid response format
|
|
"""
|
|
url = f"{DAGSMART_API_BASE}?year={year}"
|
|
print(f"Fetching {year}...", file=sys.stderr)
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
response = httpx.get(url, timeout=10.0)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
validate_api_response(data)
|
|
return data
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 429 and attempt < max_retries - 1:
|
|
# Rate limited; wait and retry with exponential backoff
|
|
wait_time = 5 * (2**attempt)
|
|
print(
|
|
f"Rate limited (429). Waiting {wait_time}s before retry "
|
|
f"({attempt + 1}/{max_retries})...",
|
|
file=sys.stderr,
|
|
)
|
|
time.sleep(wait_time)
|
|
else:
|
|
raise RuntimeError(
|
|
f"Failed to fetch calendar for year {year} from {url}: {e}"
|
|
) from e
|
|
except httpx.HTTPError as e:
|
|
raise RuntimeError(f"Failed to fetch calendar for year {year} from {url}: {e}") from e
|
|
|
|
|
|
def normalize_day(day: dict[str, Any]) -> dict[str, Any]:
|
|
"""Normalize a day from API response into our schema.
|
|
|
|
Normalizes category to consistent "de_facto" spelling and lowercases names.
|
|
|
|
A date can carry more than one `observances` entry when two distinct
|
|
holidays coincide on the same calendar date (e.g. Pingstdagen and
|
|
Sveriges nationaldag both land on 2049-06-06 and 2055-06-06, since
|
|
Pentecost is Easter + 49 days and National Day is fixed at June 6). All
|
|
observance names are joined with "; " — mirroring the same convention
|
|
`app/utils/holidays.py` already uses for a holiday that coincides with a
|
|
Sunday — so a date-collision year never silently loses one of the two
|
|
names the way taking only `observances[0]` would.
|
|
|
|
Args:
|
|
day: Day record from Dagsmart API response
|
|
|
|
Returns:
|
|
Normalized day record with our schema
|
|
"""
|
|
# Join every observance's name (usually just one) so a same-date
|
|
# collision between two distinct holidays never drops one silently.
|
|
observances = day.get("observances", [])
|
|
name_sv = "Unknown"
|
|
name_en = "Unknown"
|
|
|
|
if observances:
|
|
sv_names = [o.get("name", {}).get("sv", "") for o in observances]
|
|
en_names = [o.get("name", {}).get("en", "") for o in observances]
|
|
name_sv = "; ".join(n for n in sv_names if n) or "Unknown"
|
|
name_en = "; ".join(n for n in en_names if n) or "Unknown"
|
|
|
|
# Normalize category: vendor uses "defacto", we use "de_facto"
|
|
category = day["holidayType"]
|
|
if category == "defacto":
|
|
category = "de_facto"
|
|
|
|
# Normalize names to lowercase for consistency
|
|
name_sv = name_sv.lower() if name_sv != "Unknown" else name_sv
|
|
name_en = name_en.lower() if name_en != "Unknown" else name_en
|
|
|
|
return {
|
|
"date": day["date"],
|
|
"name_sv": name_sv,
|
|
"name_en": name_en,
|
|
"category": category,
|
|
"working_day": day["workingDay"],
|
|
}
|
|
|
|
|
|
def compute_bridge_days(year: int, non_working_dates: set[str]) -> list[dict[str, Any]]:
|
|
"""Compute bridge days (klämdagar) for a year.
|
|
|
|
A bridge day is an ordinary working day (Mon-Fri) that:
|
|
- Is not already a public or de facto day
|
|
- Has a non-working day immediately before or after
|
|
(weekends count as non-working)
|
|
|
|
These are days where employers often grant leave because taking one
|
|
vacation day bridges between two non-working days, creating a longer
|
|
continuous break.
|
|
|
|
Args:
|
|
year: Calendar year
|
|
non_working_dates: Set of date strings (YYYY-MM-DD) that are non-working
|
|
|
|
Returns:
|
|
List of bridge day records
|
|
"""
|
|
bridge_days = []
|
|
|
|
# Iterate through all days in the year
|
|
start_date = datetime.date(year, 1, 1)
|
|
end_date = datetime.date(year, 12, 31)
|
|
current = start_date
|
|
|
|
while current <= end_date:
|
|
# Check if it's a working day (Mon-Fri)
|
|
if current.weekday() < 5: # 0-4 are Mon-Fri
|
|
date_str = current.isoformat()
|
|
|
|
# Not already in non-working set
|
|
if date_str not in non_working_dates:
|
|
# Check if day before and after are both non-working
|
|
prev_day = current - datetime.timedelta(days=1)
|
|
next_day = current + datetime.timedelta(days=1)
|
|
|
|
prev_is_nonworking = (
|
|
prev_day.isoformat() in non_working_dates
|
|
or prev_day.weekday() >= 5 # Saturday or Sunday
|
|
)
|
|
next_is_nonworking = (
|
|
next_day.isoformat() in non_working_dates or next_day.weekday() >= 5
|
|
)
|
|
|
|
if prev_is_nonworking and next_is_nonworking:
|
|
bridge_days.append(
|
|
{
|
|
"date": date_str,
|
|
"name_sv": "klämdag",
|
|
"name_en": "bridge day",
|
|
"category": "bridge_day",
|
|
"working_day": True,
|
|
}
|
|
)
|
|
|
|
current += datetime.timedelta(days=1)
|
|
|
|
return bridge_days
|
|
|
|
|
|
def normalize_year(api_response: dict[str, Any]) -> dict[str, Any]:
|
|
"""Normalize API response for a year into our schema.
|
|
|
|
Includes computing bridge days (klämdagar) from the calendar.
|
|
|
|
Args:
|
|
api_response: Full API response for a year
|
|
|
|
Returns:
|
|
Normalized year record with our schema
|
|
"""
|
|
year = api_response["year"]
|
|
normalized_days = [normalize_day(d) for d in api_response["days"]]
|
|
|
|
# Sort by date
|
|
normalized_days.sort(key=lambda d: d["date"])
|
|
|
|
# Compute bridge days (klämdagar) from the calendar
|
|
non_working_dates = {d["date"] for d in normalized_days if not d["working_day"]}
|
|
bridge_days = compute_bridge_days(year, non_working_dates)
|
|
|
|
# Add bridge days to the list
|
|
normalized_days.extend(bridge_days)
|
|
|
|
# Final sort
|
|
normalized_days.sort(key=lambda d: d["date"])
|
|
|
|
return {
|
|
"year": year,
|
|
"days": normalized_days,
|
|
}
|
|
|
|
|
|
def fetch_all_years() -> dict[str, Any]:
|
|
"""Fetch and normalize calendar data for all required years.
|
|
|
|
Returns:
|
|
Complete calendar object with all years
|
|
"""
|
|
all_years = []
|
|
end_year = CURRENT_YEAR + YEARS_TO_FETCH
|
|
|
|
for year in range(CURRENT_YEAR, end_year):
|
|
try:
|
|
api_response = fetch_year(year)
|
|
normalized = normalize_year(api_response)
|
|
all_years.append(normalized)
|
|
except RuntimeError as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Rate limiting: 5 requests per 10 seconds = 2 seconds per request
|
|
time.sleep(RATE_LIMIT_DELAY)
|
|
|
|
return {
|
|
"years": all_years,
|
|
}
|
|
|
|
|
|
def write_calendar_atomic(calendar_data: dict[str, Any]) -> None:
|
|
"""Write calendar data to file atomically.
|
|
|
|
This function writes to a temporary file first, then renames it
|
|
to the target file. This ensures we never leave a partial/corrupt
|
|
cache file behind if the script fails.
|
|
|
|
Args:
|
|
calendar_data: Complete calendar object to write
|
|
"""
|
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Write to a temporary file first
|
|
temp_file = OUTPUT_FILE.with_suffix(".tmp")
|
|
|
|
try:
|
|
with open(temp_file, "w") as f:
|
|
json.dump(calendar_data, f, indent=2, sort_keys=True, ensure_ascii=False)
|
|
f.write("\n") # Add trailing newline for clean git diffs
|
|
except Exception as e:
|
|
# Clean up temp file if write failed
|
|
if temp_file.exists():
|
|
temp_file.unlink()
|
|
raise RuntimeError(f"Failed to write temporary calendar file: {e}") from e
|
|
|
|
# Atomically rename to target
|
|
try:
|
|
temp_file.replace(OUTPUT_FILE)
|
|
print(f"Wrote calendar to {OUTPUT_FILE}", file=sys.stderr)
|
|
except Exception as e:
|
|
if temp_file.exists():
|
|
temp_file.unlink()
|
|
raise RuntimeError(f"Failed to move calendar file to final location: {e}") from e
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point."""
|
|
print(
|
|
f"Fetching Swedish calendar for years {CURRENT_YEAR}-{CURRENT_YEAR + YEARS_TO_FETCH - 1}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
calendar_data = fetch_all_years()
|
|
|
|
print(f"Fetched {len(calendar_data['years'])} years", file=sys.stderr)
|
|
|
|
write_calendar_atomic(calendar_data)
|
|
|
|
print("Done.", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|