Give every year of a repeating period its own row

A repeating period was a single row that the planner expanded into one
window per year at read time, so no year had anywhere to differ from the
others. Editing one year rewrote them all, and removing a year in the
middle had to split the wish in two. Neither survives contact with the
thing this app is for: Easter moves, and the cheapest week is not the
same week every year.

Each year is now its own row, linked by a series id. Editing one year
touches one row; editing the series re-projects the rest from it.
Removing a year is one delete. The weekday snap is worked out once, when
the rows are made, and stored, so a row now says what it means rather
than being re-derived on every read. Recurrence stops being a stored
field and becomes what it always was in practice — an instruction at
create and update time; whether a period repeats is whether it belongs
to a series.

Migration 004 rebuilds the table and expands the existing rows, keeping
each original row as its first year so the days already planned against
it still point somewhere. A Feb 29 anchor recurring into a common year
used to raise out of the allocator; it clamps now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 15:53:25 +02:00
parent c6593062e3
commit 6d92b87bf3
43 changed files with 2735 additions and 638 deletions
+53 -74
View File
@@ -154,8 +154,9 @@ _FRONT_LOAD_CHILD_AGE_YEARS = 2
# side of a fixed window's placed dates for a cheaper placement. Sized to
# reliably catch a moving public holiday (Easter): the cached calendar
# (`swedish_calendar.json`, 2026-2055) shows a max year-to-year Easter Sunday
# drift of ~20 days, and `LeaveWish.window_for_year`'s weekday-preserving
# snap (fixed-mode recurrence) can shift a naive same-month/day anchor by up
# drift of ~20 days, and the weekday-preserving snap applied when a fixed
# series is materialised (`project_window`) can shift a naive same-month/day
# anchor by up
# to another 3 days -- 28 (4 weeks) covers that worst case with a small
# margin. Kept a module constant, not unbounded, so `propose()` stays cheap:
# each remedy call is O(bound * window_length) `is_free_day` lookups, each
@@ -506,54 +507,36 @@ class _WishWindow:
end: date
def _candidate_years(
wish: LeaveWish, alloc_input: AllocationInput, children_by_id: dict[UUID, ChildFacts]
) -> list[int]:
"""Anchor years a wish (fixed or flexible) might occur in, before
`applies_to_year`/horizon filtering. Shared by `_expand_all_windows` and
`_expand_flexible_wish_years` (§10.2) — identical for both, since
`window_mode` only changes how a year's `window_for_year()` result is
*used* (exact placement vs. bounding range), never which years are
considered.
"""
if wish.recurrence == "once":
return [wish.first_year]
if wish.last_year is not None:
end_year = wish.last_year
elif wish.child_id is not None and wish.child_id in children_by_id:
end_year = children_by_id[wish.child_id].fourth_birthday.year
else:
end_year = alloc_input.horizon_end.year
return list(range(wish.first_year, end_year + 1))
def _expand_all_windows(alloc_input: AllocationInput) -> list[_WishWindow]:
"""§3.2 — expand every `window_mode == 'fixed'` wish into concrete windows.
"""§3.2 — every `window_mode == 'fixed'` wish's concrete, horizon-clipped window.
Flexible wishes (§10.2) are expanded separately by
`_expand_flexible_wish_years`, since their `window_for_year()` result is a
*bounding range* to search inside, not the final placement.
One row is one year (see `app.entities.leave_wish`): a repeating wish was
materialised into per-year rows by `LeaveWishService`, each already
carrying its own dates — including the fixed-mode weekday snap, which is
applied once at materialisation rather than re-derived here. So this is
now a filter, not an expansion.
Flexible wishes (§10.2) are handled separately by
`_expand_flexible_wish_years`, since their window is a *bounding range* to
search inside, not the final placement.
"""
children_by_id = {c.id: c for c in alloc_input.children}
windows: list[_WishWindow] = []
for wish in alloc_input.wishes:
if wish.window_mode != "fixed":
continue
for year in _candidate_years(wish, alloc_input, children_by_id):
if not wish.applies_to_year(year):
continue
start, end = wish.window_for_year(year)
if end < alloc_input.today or start > alloc_input.horizon_end:
continue
clipped_start = max(start, alloc_input.today)
clipped_end = min(end, alloc_input.horizon_end)
if clipped_start > clipped_end:
continue
windows.append(
_WishWindow(wish=wish, anchor_year=year, start=clipped_start, end=clipped_end)
start, end = wish.window()
if end < alloc_input.today or start > alloc_input.horizon_end:
continue
clipped_start = max(start, alloc_input.today)
clipped_end = min(end, alloc_input.horizon_end)
if clipped_start > clipped_end:
continue
windows.append(
_WishWindow(
wish=wish, anchor_year=wish.first_year, start=clipped_start, end=clipped_end
)
)
return windows
@@ -576,31 +559,33 @@ class _FlexibleWishYear:
def _expand_flexible_wish_years(alloc_input: AllocationInput) -> list[_FlexibleWishYear]:
"""§10.2 — the flexible-wish counterpart to `_expand_all_windows`.
Reuses `applies_to_year`, the horizon clip and the year-wrap rule
unchanged; the only difference is that `window_for_year()`'s result is
treated as a bound to search inside (§10.2 step 1), not the final window.
Same horizon clip; the only difference is that the row's window is treated
as a bound to search inside (§10.2 step 1), not the final placement. Each
year of a repeating flexible wish is its own row carrying that year's
bounding range, and the per-year placement search inside it still runs at
plan time (`_Ledger.resolve_flexible_window`) — materialising the rows
changed where the range comes from, not that the search happens.
"""
children_by_id = {c.id: c for c in alloc_input.children}
wish_years: list[_FlexibleWishYear] = []
for wish in alloc_input.wishes:
if wish.window_mode != "flexible":
continue
for year in _candidate_years(wish, alloc_input, children_by_id):
if not wish.applies_to_year(year):
continue
bound_start, bound_end = wish.window_for_year(year)
if bound_end < alloc_input.today or bound_start > alloc_input.horizon_end:
continue
clipped_start = max(bound_start, alloc_input.today)
clipped_end = min(bound_end, alloc_input.horizon_end)
if clipped_start > clipped_end:
continue
wish_years.append(
_FlexibleWishYear(
wish=wish, anchor_year=year, bound_start=clipped_start, bound_end=clipped_end
)
bound_start, bound_end = wish.window()
if bound_end < alloc_input.today or bound_start > alloc_input.horizon_end:
continue
clipped_start = max(bound_start, alloc_input.today)
clipped_end = min(bound_end, alloc_input.horizon_end)
if clipped_start > clipped_end:
continue
wish_years.append(
_FlexibleWishYear(
wish=wish,
anchor_year=wish.first_year,
bound_start=clipped_start,
bound_end=clipped_end,
)
)
return wish_years
@@ -1658,7 +1643,7 @@ class _Ledger:
half_weeks = other.target_weeks // 2
if half_weeks < 1:
return []
other_start, other_end = other.window_for_year(other.first_year)
other_start, other_end = other.window()
other_dates = _dates_in(other_start, other_end)
spendable = sum(
1
@@ -2467,21 +2452,15 @@ def _to_child_facts(c: Child) -> ChildFacts:
def _compute_horizon_end(today: date, children: list[ChildFacts], wishes: list[LeaveWish]) -> date:
"""§9.1: max(last wish year-end, latest fourth_birthday), capped at age-12."""
candidates: list[date] = [c.fourth_birthday for c in children]
children_by_id = {c.id: c for c in children}
"""§9.1: max(last wish window end, latest fourth_birthday), capped at age-12.
for wish in wishes:
if wish.recurrence == "once":
end_year = wish.first_year
elif wish.last_year is not None:
end_year = wish.last_year
elif wish.child_id is not None and wish.child_id in children_by_id:
end_year = children_by_id[wish.child_id].fourth_birthday.year
else:
end_year = today.year + 4
_, end = wish.window_for_year(end_year)
candidates.append(end)
Every wish row is one concrete year now, so the horizon is simply the
latest window end across them — no recurrence projection, and no
"open-ended until the child turns 4" case to resolve here: that was
resolved once, when `LeaveWishService` materialised the series.
"""
candidates: list[date] = [c.fourth_birthday for c in children]
candidates.extend(wish.window()[1] for wish in wishes)
if not candidates:
return today + relativedelta(years=4)