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)
+285 -24
View File
@@ -1,34 +1,80 @@
"""Business logic for leave wishes (family recurring home-together intent).
"""Business logic for leave wishes (family home-together intent).
See PLANNING_ENGINE_SPEC.md §2.1. This service owns the validation the
allocator (Phase B) depends on: valid recurrence/parent_mode, sane month/day
ranges, and the parent_mode <-> parent_personal_data_uuid pairing rule.
allocator depends on (valid parent_mode, sane month/day ranges, the
parent_mode <-> parent_personal_data_uuid pairing rule) **and** the
materialisation of a repeating wish into one row per year.
Materialisation happens HERE, on create/update — not lazily at plan time.
That is the whole point of the `series_id` shape: the rows have to be real
before the user can click one year's band and edit or remove just that year.
A lazy expansion would leave nothing for the calendar to list, nothing for
`PUT /api/leave-wishes/{id}` to address, and nowhere to store the per-year
offset the planner's "shift this window onto the red days" advice produces
(Easter moves, so that advice is genuinely per year).
`recurrence` is therefore no longer a stored field. It survives only as an
*instruction* on the create/update payloads: 'yearly' means "materialise a
series from this occurrence", 'once' means "this stands alone". What comes
back is always a concrete occurrence; `series_id` (non-NULL) is what tells a
caller the occurrence belongs to a repeating series.
"""
from datetime import date, datetime
from uuid import UUID, uuid4
from app.entities.leave_wish import LeaveWish
from app.entities.leave_wish import LeaveWish, project_window
from app.repositories.child_repository import ChildRepository
from app.repositories.leave_wish_repository import LeaveWishRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.utils.errors import NotFoundError, ValidationError
from app.utils.holidays import cached_year_bounds
_VALID_RECURRENCE = {"yearly", "once"}
_VALID_PARENT_MODE = {"either", "both", "specific"}
_VALID_WINDOW_MODE = {"fixed", "flexible"}
# An edit/delete either touches the clicked year only, or every year of the
# series it belongs to. On a standalone wish the two coincide.
SCOPE_OCCURRENCE = "occurrence"
SCOPE_SERIES = "series"
_VALID_SCOPE = {SCOPE_OCCURRENCE, SCOPE_SERIES}
# A leap year, so day=29 for month=2 (Feb 29) is accepted when validating ranges.
_LEAP_REFERENCE_YEAR = 2028
# How far ahead a series is materialised when nothing else bounds it.
#
# The natural bound is the child's 4th birthday year — the app's whole premise
# (see `AnalyticsService.get_days_left_at_age_4`), and exactly what the
# allocator's own year expansion used before this change. A wish not tied to a
# child has no birthday to lean on, so it falls back to the allocator's
# childless horizon rule (`_compute_horizon_end`: four years out), counted from
# `first_year` rather than from the clock so the stored rows are deterministic.
#
# Everything is then clamped to `cached_year_bounds()` — planning past
# `swedish_calendar.json` raises `UncachedYearError`, so materialising rows the
# planner could never cost would only produce 422s. `_MAX_SERIES_LENGTH_YEARS`
# is a belt-and-braces row-count bound for the (impossible today) case of a
# missing calendar cache.
_CHILDLESS_YEARS_AHEAD = 4
_MAX_SERIES_LENGTH_YEARS = 30
class LeaveWishService:
def __init__(
self,
repo: LeaveWishRepository,
planned_leave_repo: PlannedLeaveRepository,
child_repo: ChildRepository | None = None,
) -> None:
self._repo = repo
self._planned_leave_repo = planned_leave_repo
# Optional so unit tests that only exercise single-occurrence paths can
# keep constructing the service with two repos. Without it, an
# open-ended ("until the child turns 4") series falls back to the
# childless horizon instead of the child's actual 4th birthday.
self._child_repo = child_repo
async def create(
self,
@@ -49,8 +95,25 @@ class LeaveWishService:
parent_mode: str = "either",
parent_personal_data_uuid: UUID | None = None,
window_mode: str = "fixed",
series_id: UUID | None = None,
wants_dubbeldag: bool = False,
) -> LeaveWish:
"""Create one occurrence, or a whole materialised series.
`recurrence='yearly'` mints a `series_id` and writes one row per year
from `first_year` through the resolved series end (see
`_resolve_series_end_year`). The returned entity is always the
`first_year` occurrence — the one whose window is exactly what the
caller asked for, and the one a caller watching "the wish it just
created" means.
`series_id` (only with `recurrence='once'`) re-attaches a single
occurrence to an EXISTING series in the same workspace — the undo of
a "remove just this year", which must put the year back as part of
the repeating wish rather than as a detached one-off. The series'
`last_year` intent is copied from its surviving rows so the mirror
stays consistent.
"""
_validate(
window_start_month=window_start_month,
window_start_day=window_start_day,
@@ -66,8 +129,20 @@ class LeaveWishService:
window_mode=window_mode,
wants_dubbeldag=wants_dubbeldag,
)
repeats = recurrence == "yearly"
if series_id is not None and repeats:
raise ValidationError(
"series_id may not be combined with recurrence='yearly' "
"(a repeating wish mints its own series)"
)
if series_id is not None:
existing = await self._repo.get_by_series(series_id)
if not existing or existing[0].workspace_id != workspace_id:
raise ValidationError(f"series_id {series_id} is not a series in this workspace")
last_year = existing[0].last_year
repeats = True # it IS part of a series; it just doesn't create one
now = datetime.now()
entity = LeaveWish(
anchor = LeaveWish(
id=uuid4(),
workspace_id=workspace_id,
child_id=child_id,
@@ -79,18 +154,23 @@ class LeaveWishService:
window_end_day=window_end_day,
target_weeks=target_weeks,
target_days=target_days,
recurrence=recurrence,
first_year=first_year,
last_year=last_year,
last_year=last_year if repeats else None,
priority=priority,
parent_mode=parent_mode,
parent_personal_data_uuid=parent_personal_data_uuid,
window_mode=window_mode,
series_id=series_id if series_id is not None else (uuid4() if repeats else None),
wants_dubbeldag=wants_dubbeldag,
created_at=now,
updated_at=now,
)
return await self._repo.create(entity)
created = await self._repo.create(anchor)
if repeats and series_id is None:
end_year = await self._resolve_series_end_year(child_id, first_year, last_year)
for year in range(first_year + 1, end_year + 1):
await self._repo.create(_project_occurrence(created, year))
return created
async def get(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> LeaveWish:
"""`workspace_id`, when given, must match the entity's own — callers
@@ -128,6 +208,7 @@ class LeaveWishService:
parent_personal_data_uuid: UUID | None = None,
window_mode: str | None = None,
wants_dubbeldag: bool | None = None,
scope: str = SCOPE_OCCURRENCE,
_clear_child_id: bool = False,
_clear_last_year: bool = False,
_clear_parent_personal_data_uuid: bool = False,
@@ -136,6 +217,19 @@ class LeaveWishService:
) -> LeaveWish:
"""Partial update — only non-None fields are applied.
`scope` decides how far the edit reaches:
* `'occurrence'` (default) — this year's row only. Siblings are not
touched, which is the point of materialising in the first place.
* `'series'` — every row sharing this row's `series_id`. The edited
row becomes the series' new anchor and every other year is
re-projected from it (`project_window`), so a fixed wish keeps its
weekday alignment across the series exactly as before.
`recurrence` is an instruction, not a field: `'yearly'` on a
standalone wish materialises a series around it; `'once'` on a series
occurrence keeps this row and deletes its siblings.
`_clear_*` flags exist because a nullable field's "no change" and
"set to None" both look like `None` on the wire; the router passes
them based on whether the field was present in the request payload.
@@ -149,9 +243,24 @@ class LeaveWishService:
`_clear_target_days` resets a day-granular wish back to being sized
by `target_weeks` alone.
"""
if scope not in _VALID_SCOPE:
raise ValidationError(f"scope must be one of {_VALID_SCOPE}")
if recurrence is not None and recurrence not in _VALID_RECURRENCE:
raise ValidationError(f"recurrence must be one of {_VALID_RECURRENCE}")
entity = await self.get(leave_wish_id, workspace_id)
was_series = entity.series_id is not None
original_name = entity.name
# What the caller asked the row's repeat-ness to become. Absent
# `recurrence` means "leave it as it is".
will_repeat = was_series if recurrence is None else recurrence == "yearly"
touches_last_year = _clear_last_year or last_year is not None
if touches_last_year and will_repeat and scope != SCOPE_SERIES and was_series:
raise ValidationError(
"last_year is a property of the whole series; pass scope='series' to change it"
)
if _clear_child_id:
entity.child_id = None
elif child_id is not None:
@@ -178,14 +287,8 @@ class LeaveWishService:
entity.target_days = None
elif target_days is not None:
entity.target_days = target_days
if recurrence is not None:
entity.recurrence = recurrence
if first_year is not None:
entity.first_year = first_year
if _clear_last_year:
entity.last_year = None
elif last_year is not None:
entity.last_year = last_year
if priority is not None:
entity.priority = priority
if parent_mode is not None:
@@ -199,6 +302,16 @@ class LeaveWishService:
if wants_dubbeldag is not None:
entity.wants_dubbeldag = wants_dubbeldag
# `last_year` is the series' intent, mirrored on every row. A
# standalone occurrence never carries one.
if will_repeat:
if _clear_last_year:
entity.last_year = None
elif last_year is not None:
entity.last_year = last_year
else:
entity.last_year = None
_validate(
window_start_month=entity.window_start_month,
window_start_day=entity.window_start_day,
@@ -206,7 +319,7 @@ class LeaveWishService:
window_end_day=entity.window_end_day,
target_weeks=entity.target_weeks,
target_days=entity.target_days,
recurrence=entity.recurrence,
recurrence="yearly" if will_repeat else "once",
first_year=entity.first_year,
last_year=entity.last_year,
parent_mode=entity.parent_mode,
@@ -214,10 +327,42 @@ class LeaveWishService:
window_mode=entity.window_mode,
wants_dubbeldag=entity.wants_dubbeldag,
)
return await self._repo.update(leave_wish_id, entity)
async def delete(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> None:
"""Delete a wish and every `planned_leave_day` row it produced.
siblings = (
[s for s in await self._repo.get_by_series(entity.series_id) if s.id != entity.id]
if entity.series_id is not None
else []
)
if was_series and not will_repeat:
# "Stop repeating": keep the clicked occurrence, drop the rest.
entity.series_id = None
await self._delete_rows(siblings)
return await self._repo.update(entity.id, entity)
if not will_repeat:
return await self._repo.update(entity.id, entity)
if not was_series:
# "Make this repeat": this occurrence becomes the series anchor.
entity.series_id = uuid4()
anchor = await self._repo.update(entity.id, entity)
if was_series and scope != SCOPE_SERIES:
# A single year of an existing series changed; siblings keep
# whatever the user (or the planner) had already given them.
return anchor
await self._materialise_series(anchor, siblings)
return anchor
async def delete(
self,
leave_wish_id: UUID,
workspace_id: UUID | None = None,
scope: str = SCOPE_OCCURRENCE,
) -> None:
"""Delete a wish occurrence (or a whole series) and every
`planned_leave_day` row it produced.
Under the calendar-direct-manipulation UX a period *is* its wish: the
band the user drew, the days the allocator filled it with, and any
@@ -237,10 +382,122 @@ class LeaveWishService:
(`Calendar.handleDeletePeriod`'s no-`wishId` branch), which already
deletes every day in the band regardless of `is_pinned` with no
special-casing.
`scope='series'` removes every year of the repeating wish this
occurrence belongs to; `'occurrence'` removes just this year's row,
which is now a plain delete rather than the range-narrowing (and
split-the-wish-in-two) contortion the single-row shape forced on the
frontend.
"""
await self.get(leave_wish_id, workspace_id) # raises NotFoundError if missing/foreign
await self._planned_leave_repo.delete_by_wish(leave_wish_id)
await self._repo.delete(leave_wish_id)
if scope not in _VALID_SCOPE:
raise ValidationError(f"scope must be one of {_VALID_SCOPE}")
entity = await self.get(leave_wish_id, workspace_id)
targets = [entity]
if scope == SCOPE_SERIES and entity.series_id is not None:
targets = await self._repo.get_by_series(entity.series_id)
await self._delete_rows(targets)
# -- internals ---------------------------------------------------------
async def _delete_rows(self, rows: list[LeaveWish]) -> None:
for row in rows:
await self._planned_leave_repo.delete_by_wish(row.id)
await self._repo.delete(row.id)
async def _materialise_series(self, anchor: LeaveWish, siblings: list[LeaveWish]) -> None:
"""Make the rows of `anchor`'s series agree with `anchor`.
Every year in the series is re-projected from `anchor` — that is what
"apply this to every year" means, and it is the same projection the
old single-row shape ran at read time, just written down. Years past
the (possibly newly shortened) end are deleted; years newly inside it
are created. The anchor row itself is never touched here: it already
holds exactly the dates the user typed.
"""
assert anchor.series_id is not None # only called on a series row
end_year = await self._resolve_series_end_year(
anchor.child_id, anchor.first_year, anchor.last_year
)
start_year = min([anchor.first_year, *(s.first_year for s in siblings)])
wanted = set(range(start_year, end_year + 1))
by_year: dict[int, LeaveWish] = {}
stale: list[LeaveWish] = []
for sibling in siblings:
if sibling.first_year in wanted and sibling.first_year not in by_year:
by_year[sibling.first_year] = sibling
else:
# Out of range, or a duplicate year (only reachable if a
# `first_year` edit collided with an existing sibling).
stale.append(sibling)
await self._delete_rows(stale)
for year in sorted(wanted - {anchor.first_year}):
projected = _project_occurrence(anchor, year)
existing = by_year.get(year)
if existing is None:
await self._repo.create(projected)
continue
projected.id = existing.id
projected.created_at = existing.created_at
await self._repo.update(existing.id, projected)
async def _resolve_series_end_year(
self, child_id: UUID | None, first_year: int, last_year: int | None
) -> int:
"""Last anchor year a series is materialised for. See the module's
`_CHILDLESS_YEARS_AHEAD` comment for why these bounds and not others.
"""
if last_year is not None:
end_year = last_year
else:
end_year = first_year + _CHILDLESS_YEARS_AHEAD
if child_id is not None and self._child_repo is not None:
child = await self._child_repo.get_by_id(child_id)
if child is not None:
end_year = child.birth_date.year + 4
bounds = cached_year_bounds()
if bounds is not None:
end_year = min(end_year, bounds[1])
end_year = min(end_year, first_year + _MAX_SERIES_LENGTH_YEARS)
return max(end_year, first_year)
def _project_occurrence(anchor: LeaveWish, year: int) -> LeaveWish:
"""A brand-new row for `year`, carrying `anchor`'s projected window."""
start, end = project_window(
start_month=anchor.window_start_month,
start_day=anchor.window_start_day,
end_month=anchor.window_end_month,
end_day=anchor.window_end_day,
anchor_year=anchor.first_year,
year=year,
window_mode=anchor.window_mode,
)
now = datetime.now()
return LeaveWish(
id=uuid4(),
workspace_id=anchor.workspace_id,
child_id=anchor.child_id,
name=anchor.name,
name_message_key=anchor.name_message_key,
window_start_month=start.month,
window_start_day=start.day,
window_end_month=end.month,
window_end_day=end.day,
target_weeks=anchor.target_weeks,
target_days=anchor.target_days,
first_year=year,
last_year=anchor.last_year,
priority=anchor.priority,
parent_mode=anchor.parent_mode,
parent_personal_data_uuid=anchor.parent_personal_data_uuid,
window_mode=anchor.window_mode,
series_id=anchor.series_id,
wants_dubbeldag=anchor.wants_dubbeldag,
created_at=now,
updated_at=now,
)
def _validate(
@@ -276,6 +533,10 @@ def _validate(
if target_days is not None and target_days < 1:
raise ValidationError("target_days must be >= 1")
# `last_year` is the series' end-year intent and is meaningless on a
# standalone occurrence; `create`/`update` normalise it to None there
# rather than rejecting it, so an older client that still sends one on a
# 'once' wish keeps working.
if last_year is not None and last_year < first_year:
raise ValidationError("last_year must be >= first_year")
@@ -326,9 +587,9 @@ def _bound_length_days(
) -> int:
"""Span (inclusive, in days) of a window/bound anchored at `anchor_year`.
Mirrors `LeaveWish.window_wraps()`/`window_for_year()` without requiring a
full entity — used at validation time (§10.1), before an entity exists in
`create()`. `anchor_year` should be a real calendar year (not the leap
Mirrors `LeaveWish.window_wraps()`/`LeaveWish.window()` without requiring
a full entity — used at validation time (§10.1), before an entity exists
in `create()`. `anchor_year` should be a real calendar year (not the leap
reference year `_validate_month_day` uses) so Feb 29 in a non-leap
`first_year` naturally raises via `date()`, consistent with
`_validate_month_day`'s own calendar-date check.
+10 -12
View File
@@ -481,20 +481,18 @@ async def _rule_wrap_red_days_with_vacation(ctx: TipsContext) -> Tip | None:
def _concrete_windows_near_today(wish: LeaveWish, today: date) -> list[tuple[date, date]]:
"""Concretize a recurring wish into the window(s) anchored near `today`.
"""This wish's window, if it is anchored near `today` — else nothing.
Only a small neighbourhood of anchor years is considered (last year,
this year, next year) — enough to catch a window that started last
December and wraps into January (see LeaveWish.window_for_year) without
materialising the wish's entire multi-year horizon. Full expansion across
the whole horizon is the allocator's job (PLANNING_ENGINE_SPEC.md §3.2),
not the tips engine's.
One wish row is one year (see `app.entities.leave_wish`), so there is
nothing to concretize any more; the tips engine just ignores occurrences
far from today. The neighbourhood is last year / this year / next year —
"last year" is what catches a window that started last December and wraps
into January. Reasoning over the whole horizon is the allocator's job
(PLANNING_ENGINE_SPEC.md §3.2), not the tips engine's.
"""
windows = []
for year in (today.year - 1, today.year, today.year + 1):
if wish.applies_to_year(year):
windows.append(wish.window_for_year(year))
return windows
if wish.first_year not in (today.year - 1, today.year, today.year + 1):
return []
return [wish.window()]
async def _rule_long_break_parent_swap(ctx: TipsContext) -> Tip | None: