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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user