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:
@@ -1,17 +1,29 @@
|
||||
"""Domain entity: LeaveWish.
|
||||
|
||||
A family's recurring intent to be home for N weeks inside a yearly window
|
||||
(e.g. "6 weeks every summer until the child turns 4"). Renamed and extended
|
||||
from `BreakPeriod` (see PLANNING_ENGINE_SPEC.md §2.1): the old entity was
|
||||
single-year with no notion of recurrence, priority, or which parent. This is
|
||||
the input side of the planning engine's allocator (§3); the allocator itself
|
||||
is Phase B.
|
||||
A family's intent to be home for N weeks inside a window of ONE year
|
||||
(e.g. "6 weeks in the summer of 2028"). Renamed and extended from
|
||||
`BreakPeriod` (see PLANNING_ENGINE_SPEC.md §2.1).
|
||||
|
||||
The recurring window is stored as month/day integers (not dates) so it can
|
||||
repeat yearly without a custom SQLite type. If the window's end sorts before
|
||||
its start, it wraps into the next year — e.g. Jullov 12-20 -> 01-07.
|
||||
**One row = one occurrence.** A wish the user asked to repeat every year is
|
||||
*materialised* by `LeaveWishService` into one row per year, all sharing a
|
||||
`series_id`; a genuinely one-off wish has `series_id = None`. There is no
|
||||
`recurrence` field any more — a row either stands alone or belongs to a
|
||||
series, and either way it describes exactly one year's window.
|
||||
|
||||
That materialisation is what makes per-year divergence possible: Easter (and
|
||||
therefore the planner's "shift this window onto the red days" advice) moves
|
||||
from year to year, so every year needs its own editable row to carry its own
|
||||
offset. Editing one year touches one row; editing "every year" re-projects
|
||||
the whole series from the edited row.
|
||||
|
||||
The window is still stored as month/day integers plus `first_year` (the year
|
||||
the window starts in) rather than two dates — that is the existing column
|
||||
layout, and it keeps the wrap rule ("end sorts before start" => the end lands
|
||||
in `first_year + 1`, e.g. Jullov 12-20 -> 01-07) expressible without a custom
|
||||
SQLite type.
|
||||
"""
|
||||
|
||||
import calendar
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from uuid import UUID
|
||||
@@ -32,25 +44,93 @@ def _shift_to_weekday(naive: date, target_weekday: int) -> date:
|
||||
return naive + timedelta(days=shift)
|
||||
|
||||
|
||||
def clamped_date(year: int, month: int, day: int) -> date:
|
||||
"""`date(year, month, day)`, with `day` clamped to the month's real length.
|
||||
|
||||
Only Feb 29 in a non-leap year can actually trigger the clamp for a
|
||||
stored window (the service's `_validate_month_day` already rejects
|
||||
month/day pairs that are not a real date in a leap year). A wish anchored
|
||||
on Feb 29 used to raise `ValueError` out of the allocator the first time
|
||||
it recurred into a non-leap year; clamping to Feb 28 is the only sane
|
||||
answer for a whole-day planner and never changes any other date.
|
||||
"""
|
||||
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
|
||||
|
||||
|
||||
def window_wraps_month_day(start_month: int, start_day: int, end_month: int, end_day: int) -> bool:
|
||||
"""Whether (end_month, end_day) sorts before (start_month, start_day)."""
|
||||
return (end_month, end_day) < (start_month, start_day)
|
||||
|
||||
|
||||
def project_window(
|
||||
*,
|
||||
start_month: int,
|
||||
start_day: int,
|
||||
end_month: int,
|
||||
end_day: int,
|
||||
anchor_year: int,
|
||||
year: int,
|
||||
window_mode: str,
|
||||
) -> tuple[date, date]:
|
||||
"""The concrete window an anchor occurrence projects onto `year`.
|
||||
|
||||
This is the materialisation step: it is called ONCE per year, by
|
||||
`LeaveWishService`, and the result is written into that year's own row.
|
||||
Nothing re-derives it at read time — `LeaveWish.window()` just reads the
|
||||
dates back off the row.
|
||||
|
||||
`window_mode == 'fixed'` wishes describe a week-based habit, not a literal
|
||||
month/day: someone who is off Mon-Sun this year means Mon-Sun again next
|
||||
year, even though the naive month/day anchor isn't a Monday every year
|
||||
(the reported bug: 2026-10-05..10-11, Mon-Sun, naively recurred to
|
||||
2027-10-05..10-11, which is Tue-Mon). So for any `year` other than
|
||||
`anchor_year`, the naive `date(year, start_month, start_day)` is snapped
|
||||
to the nearest date carrying the anchor's *start weekday* (ties resolve to
|
||||
the earlier date — see `_shift_to_weekday`), and the end date is
|
||||
re-derived by holding the anchor window's day-*span* fixed rather than
|
||||
re-anchoring end_month/end_day. `anchor_year` itself is unshifted.
|
||||
|
||||
`window_mode == 'flexible'` wishes (§10.2) describe a BOUNDING RANGE the
|
||||
allocator searches inside, not a placement — they keep the plain month/day
|
||||
anchor with no weekday shift, every year. Each year's row therefore
|
||||
carries that year's bounding range, and the per-year placement search
|
||||
inside it still happens at plan time (`_Ledger.resolve_flexible_window`).
|
||||
"""
|
||||
wraps = window_wraps_month_day(start_month, start_day, end_month, end_day)
|
||||
naive_start = clamped_date(year, start_month, start_day)
|
||||
naive_end = clamped_date(year + 1 if wraps else year, end_month, end_day)
|
||||
|
||||
if window_mode != "fixed" or year == anchor_year:
|
||||
return naive_start, naive_end
|
||||
|
||||
anchor_start = clamped_date(anchor_year, start_month, start_day)
|
||||
anchor_end = clamped_date(anchor_year + 1 if wraps else anchor_year, end_month, end_day)
|
||||
span = anchor_end - anchor_start
|
||||
|
||||
start = _shift_to_weekday(naive_start, anchor_start.weekday())
|
||||
return start, start + span
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeaveWish:
|
||||
"""A family's recurring intent to be home for N weeks inside a yearly window."""
|
||||
"""One year's intent to be home for N weeks inside a window."""
|
||||
|
||||
id: UUID
|
||||
workspace_id: UUID
|
||||
child_id: UUID | None # None = family-wide; set = tied to that child's pools
|
||||
name: str # "Sommar", "Jullov" — persisted, possibly-translated display text
|
||||
|
||||
# Recurring anchor stored as month/day ints (SQLite-friendly, no custom type).
|
||||
# The window, stored as month/day ints anchored at `first_year`
|
||||
# (SQLite-friendly, no custom type). These are this occurrence's REAL
|
||||
# dates — including the weekday snap a repeating wish got when it was
|
||||
# materialised — not a pattern to be re-derived per year.
|
||||
window_start_month: int # 1-12
|
||||
window_start_day: int # 1-31
|
||||
window_end_month: int
|
||||
window_end_day: int
|
||||
|
||||
target_weeks: int # 6
|
||||
recurrence: str # 'yearly' | 'once'
|
||||
first_year: int # 2027
|
||||
last_year: int | None # None = until the child's 4th birthday
|
||||
first_year: int # the (single) year this occurrence's window starts in
|
||||
|
||||
priority: int # 1 = highest; drives best-effort fill order
|
||||
parent_mode: str # 'either' | 'both' | 'specific'
|
||||
@@ -60,12 +140,25 @@ class LeaveWish:
|
||||
# are the exact placement, as above. 'flexible': the SAME four fields instead
|
||||
# describe the BOUNDING RANGE the allocator searches inside for the
|
||||
# best-costing placement (e.g. "vår" = 03-01..05-31) — target_weeks is still
|
||||
# the wanted duration inside that range. recurrence/first_year/last_year are
|
||||
# unchanged, so a 'yearly' flexible wish still re-resolves its bounding range
|
||||
# every anchor year through window_for_year()/applies_to_year() below,
|
||||
# completely unmodified.
|
||||
# the wanted duration inside that range.
|
||||
window_mode: str = "fixed" # 'fixed' | 'flexible'
|
||||
|
||||
# Groups the per-year rows a repeating wish was materialised into. `None`
|
||||
# = a standalone, genuinely one-off occurrence. Every row of one series
|
||||
# shares this id; the service is the only writer, and it keeps the rows
|
||||
# of a series consistent (same name/parent/series bound, one row per year).
|
||||
series_id: UUID | None = None
|
||||
|
||||
# SERIES-LEVEL intent, mirrored identically onto every row of a series:
|
||||
# the final anchor year the user asked for, or `None` = "until the child
|
||||
# turns 4" (the app's premise — see `AnalyticsService.get_days_left_at_age_4`).
|
||||
# Always `None` when `series_id is None`. Denormalised deliberately: the
|
||||
# UI reads the repeat-until control straight off whichever occurrence was
|
||||
# clicked, and re-materialising a series after an edit needs the intent,
|
||||
# not just the rows that happen to exist. `LeaveWishService` is the sole
|
||||
# writer and rewrites it across the series whenever it changes.
|
||||
last_year: int | None = None
|
||||
|
||||
# §Decision A (co-leave, 2026-09-18 correction) — dubbeldagar are OPT-IN,
|
||||
# never automatic, even when `parent_mode == 'both'` and the child is
|
||||
# still inside the 15-month dubbeldagar window with quota left. Only set
|
||||
@@ -108,63 +201,28 @@ class LeaveWish:
|
||||
"""Whether (end_month, end_day) sorts before (start_month, start_day).
|
||||
|
||||
A wrapping window (e.g. Jullov 12-20 -> 01-07) ends in the year *after*
|
||||
the anchor year passed to `window_for_year`.
|
||||
`first_year`.
|
||||
"""
|
||||
return (self.window_end_month, self.window_end_day) < (
|
||||
return window_wraps_month_day(
|
||||
self.window_start_month,
|
||||
self.window_start_day,
|
||||
self.window_end_month,
|
||||
self.window_end_day,
|
||||
)
|
||||
|
||||
def window_for_year(self, year: int) -> tuple[date, date]:
|
||||
"""Concrete (start_date, end_date) for this wish's window anchored at `year`.
|
||||
def window(self) -> tuple[date, date]:
|
||||
"""This occurrence's concrete (start_date, end_date), inclusive.
|
||||
|
||||
`year` is the year the window *starts* in. If the window wraps, the
|
||||
end date lands in `year + 1` — this also means a wrapping window spans
|
||||
two different vacation-year budgets (see PLANNING_ENGINE_SPEC.md §3.2);
|
||||
callers that debit vacation must account for both years separately.
|
||||
A pure read of the stored fields — no recurrence projection, no
|
||||
weekday snapping. Whatever shifting this occurrence needed was applied
|
||||
once, at materialisation (`project_window`), and written into the row.
|
||||
|
||||
`window_mode == 'fixed'` wishes describe a week-based habit, not a
|
||||
literal month/day: someone who is off Mon-Sun this year means Mon-Sun
|
||||
again next year, even though the naive month/day anchor isn't a
|
||||
Monday every year (the reported bug: 2026-10-05..10-11, Mon-Sun,
|
||||
naively recurred to 2027-10-05..10-11, which is Tue-Mon). So for any
|
||||
`year` after `first_year`, the naive `date(year, start_month,
|
||||
start_day)` is snapped to the nearest date carrying `first_year`'s
|
||||
*start weekday* (ties resolve to the earlier date — see
|
||||
`_shift_to_weekday`), and the end date is re-derived by holding the
|
||||
anchor window's day-*span* fixed rather than re-anchoring
|
||||
end_month/end_day. `first_year` itself is always returned unshifted.
|
||||
|
||||
`window_mode == 'flexible'` wishes (§10.2) describe a BOUNDING RANGE
|
||||
the allocator searches inside, not a placement — they keep the plain
|
||||
month/day anchor with no weekday shift, every year.
|
||||
If the window wraps, the end date lands in `first_year + 1` — which
|
||||
also means it spans two vacation-year budgets (see
|
||||
PLANNING_ENGINE_SPEC.md §3.2); callers that debit vacation must
|
||||
account for both years separately.
|
||||
"""
|
||||
naive_start = date(year, self.window_start_month, self.window_start_day)
|
||||
naive_end_year = year + 1 if self.window_wraps() else year
|
||||
naive_end = date(naive_end_year, self.window_end_month, self.window_end_day)
|
||||
|
||||
if self.window_mode != "fixed" or year == self.first_year:
|
||||
return naive_start, naive_end
|
||||
|
||||
anchor_start = date(self.first_year, self.window_start_month, self.window_start_day)
|
||||
anchor_end_year = self.first_year + 1 if self.window_wraps() else self.first_year
|
||||
anchor_end = date(anchor_end_year, self.window_end_month, self.window_end_day)
|
||||
span = anchor_end - anchor_start
|
||||
|
||||
start = _shift_to_weekday(naive_start, anchor_start.weekday())
|
||||
end = start + span
|
||||
start = clamped_date(self.first_year, self.window_start_month, self.window_start_day)
|
||||
end_year = self.first_year + 1 if self.window_wraps() else self.first_year
|
||||
end = clamped_date(end_year, self.window_end_month, self.window_end_day)
|
||||
return start, end
|
||||
|
||||
def applies_to_year(self, year: int) -> bool:
|
||||
"""Whether this wish recurs (or occurs once) with an anchor of `year`.
|
||||
|
||||
`year` is compared against `first_year`/`last_year` as an *anchor*
|
||||
year (the year the window starts in), not a calendar year the window
|
||||
merely overlaps.
|
||||
"""
|
||||
if year < self.first_year:
|
||||
return False
|
||||
if self.recurrence == "once":
|
||||
return year == self.first_year
|
||||
# recurrence == 'yearly'
|
||||
return self.last_year is None or year <= self.last_year
|
||||
|
||||
Reference in New Issue
Block a user