Keep a repeating week on its weekday when the planner picks the dates

A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.

project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.

_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.

_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.

Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.

Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.

Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
2026-09-20 12:04:20 +02:00
parent fdc6439614
commit ad0e3c0b33
19 changed files with 1217 additions and 72 deletions
@@ -179,7 +179,14 @@ def test_open_ended_yearly_wish_stops_at_the_childs_fourth_birthday_year(tmp_pat
)
def test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year(tmp_path: Path) -> None:
def test_flexible_yearly_wish_keeps_its_bounding_range_weekday_aligned(tmp_path: Path) -> None:
"""`command.upgrade(..., "head")` runs 004's backfill AND 008's
realignment, so this proves the two migrations agree with each other:
004 backfills the pre-fix (unsnapped) flexible bound, and 008 — since
"head" now includes it — snaps it the same way `project_window` does
post-fix, rather than leaving it at 004's naive output.
"""
db = tmp_path / "flexible.db"
command.upgrade(_alembic_config(db), "003")
@@ -191,7 +198,7 @@ def test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year(tmp_path:
"id": str(uuid.uuid4()),
"name": "Var",
"wsm": 3,
"wsd": 1,
"wsd": 1, # 2027-03-01 is a Monday
"wem": 5,
"wed": 31,
"target_weeks": 1,
@@ -207,12 +214,17 @@ def test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year(tmp_path:
rows = _rows(db, "Var")
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
# A flexible wish's four fields are a bounding range, never a placement —
# no weekday snap, so every year's range is the identical month/day pair.
assert {
(r["window_start_month"], r["window_start_day"], r["window_end_month"], r["window_end_day"])
for r in rows
} == {(3, 1, 5, 31)}
# The bound keeps its weekday alignment (Monday, like the anchor) and
# its exact 91-day span, sliding a few days per year rather than staying
# pinned to the identical naive month/day.
stored = [(r["window_start_month"], r["window_start_day"]) for r in rows]
assert stored == [(3, 1), (2, 28), (2, 26)]
for row in rows:
start = date(row["first_year"], row["window_start_month"], row["window_start_day"])
# Never wraps (May > March), so the end date stays in the same year.
end = date(row["first_year"], row["window_end_month"], row["window_end_day"])
assert start.weekday() == 0, row["first_year"]
assert (end - start).days == 91, row["first_year"]
def test_once_wish_is_left_alone_with_no_series(tmp_path: Path) -> None: