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
This commit is contained in:
2026-09-14 07:38:07 +02:00
parent 28c632ca36
commit 249e277696
15 changed files with 4828 additions and 53 deletions
+13 -10
View File
@@ -2,7 +2,7 @@
Single source of truth for "is this day free" across the app (planning
engine, tips engine). Backed by `app/data/swedish_calendar.json` — a
validated 10-year cache (2026-2035) built from the Dagsmart API and
validated ~30-year cache (2026-2055) built from the Dagsmart API and
cross-checked against the `holidays` library. For years outside the cached
range, falls back to the `holidays` library directly; the fallback only
knows formal röda dagar (+ every Sunday), not de facto days or klämdagar.
@@ -107,14 +107,17 @@ class UncachedYearError(RuntimeError):
"""Raised by a `strict=True` caller when `year` falls outside
`swedish_calendar.json`'s range (or the cache file is missing).
This should never fire in normal operation: callers that pass
`strict=True` (currently only `CalendarService`) are expected to clamp
their requested window to `cached_year_bounds()` first. Hitting this is
therefore a bug in that clamping, not bad user input — fail loudly
instead of silently falling back to the `holidays` library, which gives
strictly poorer answers for the same dates (missing de facto days like
Julafton/Nyårsafton/Midsommarafton) and would defeat the whole point of
the cache (see module docstring; §6 offline/deterministic guarantee).
This should never fire in normal operation: `CalendarService` clamps its
requested window to `cached_year_bounds()` first, and the planning
engine's `AllocationInputLoader` surfaces this as a `ValidationError`
instead of letting it escape as a raw `RuntimeError` — see
`app.services.allocation_service.AllocationInputLoader.load`. Hitting
this in either caller is therefore a bug in that guard, not bad user
input — fail loudly instead of silently falling back to the `holidays`
library, which gives strictly poorer answers for the same dates (missing
de facto days like Julafton/Nyårsafton/Midsommarafton) and would defeat
the whole point of the cache (see module docstring; §6 offline/
deterministic guarantee).
"""
@@ -205,7 +208,7 @@ def cached_year_bounds() -> tuple[int, int] | None:
cache file is missing.
Used by `app.services.calendar_service` to validate/paginate the
calendar-days endpoint window without hardcoding the 10-year range.
calendar-days endpoint window without hardcoding the cached-year range.
"""
years = _load_raw_cache()
if not years: