ad0e3c0b33
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
298 lines
10 KiB
Python
298 lines
10 KiB
Python
"""Migration 004's backfill: one `recurrence='yearly'` row -> one row per year.
|
|
|
|
`test_schema_parity.py` proves the migration chain and `_FULL_SCHEMA` describe
|
|
the same *shape*. This proves 004 moves the *data* correctly: an existing
|
|
repeating wish must come out the other side as a real row per year, sharing a
|
|
`series_id`, each carrying the dates the pre-004 `window_for_year()` would
|
|
have produced for that year — including the fixed-mode weekday snap. Getting
|
|
that wrong would silently move every recurring family's summer.
|
|
|
|
Driven through the real Alembic chain (upgrade to 003, insert pre-004 rows,
|
|
upgrade to head), not through `_FULL_SCHEMA`, because the pre-004 shape no
|
|
longer exists anywhere else.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import uuid
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
|
|
|
|
def _alembic_config(db_path: Path) -> Config:
|
|
cfg = Config("alembic.ini")
|
|
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
|
|
return cfg
|
|
|
|
|
|
def _insert_pre_004_fixture(
|
|
db_path: Path,
|
|
*,
|
|
child_birth_date: date | None,
|
|
wishes: list[dict[str, object]],
|
|
) -> dict[str, str]:
|
|
"""Seed a workspace (+ optional child) and the given pre-004 leave_wish rows."""
|
|
now = datetime(2026, 1, 1, 12, 0, 0).isoformat()
|
|
ws_id = str(uuid.uuid4())
|
|
child_id = str(uuid.uuid4()) if child_birth_date is not None else None
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)",
|
|
(ws_id, "WS", now),
|
|
)
|
|
if child_id is not None:
|
|
assert child_birth_date is not None
|
|
conn.execute(
|
|
"INSERT INTO child (id, workspace_id, name, birth_date, "
|
|
"preschool_start_date, preschool_planning_days_per_year, created_at) "
|
|
"VALUES (?, ?, ?, ?, NULL, 0, ?)",
|
|
(child_id, ws_id, "Barn", child_birth_date.isoformat(), now),
|
|
)
|
|
for wish in wishes:
|
|
conn.execute(
|
|
"INSERT INTO leave_wish (id, workspace_id, child_id, name, "
|
|
"window_start_month, window_start_day, window_end_month, window_end_day, "
|
|
"target_weeks, recurrence, first_year, last_year, priority, parent_mode, "
|
|
"window_mode, created_at, updated_at) "
|
|
"VALUES (:id, :workspace_id, :child_id, :name, :wsm, :wsd, :wem, :wed, "
|
|
":target_weeks, :recurrence, :first_year, :last_year, 1, 'either', "
|
|
":window_mode, :now, :now)",
|
|
{
|
|
"workspace_id": ws_id,
|
|
"child_id": child_id if wish.get("tied_to_child", True) else None,
|
|
"now": now,
|
|
**{k: v for k, v in wish.items() if k != "tied_to_child"},
|
|
},
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return {"workspace_id": ws_id, "child_id": child_id or ""}
|
|
|
|
|
|
def _rows(db_path: Path, name: str) -> list[sqlite3.Row]:
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
return conn.execute(
|
|
"SELECT * FROM leave_wish WHERE name = ? ORDER BY first_year", (name,)
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_yearly_wish_is_expanded_into_one_row_per_year(tmp_path: Path) -> None:
|
|
db = tmp_path / "backfill.db"
|
|
command.upgrade(_alembic_config(db), "003")
|
|
|
|
anchor_id = str(uuid.uuid4())
|
|
_insert_pre_004_fixture(
|
|
db,
|
|
child_birth_date=date(2026, 5, 1),
|
|
wishes=[
|
|
{
|
|
"id": anchor_id,
|
|
"name": "Sommar",
|
|
# 2026-10-05 is a Monday; 10-11 the Sunday after.
|
|
"wsm": 10,
|
|
"wsd": 5,
|
|
"wem": 10,
|
|
"wed": 11,
|
|
"target_weeks": 1,
|
|
"recurrence": "yearly",
|
|
"first_year": 2026,
|
|
"last_year": 2029,
|
|
"window_mode": "fixed",
|
|
}
|
|
],
|
|
)
|
|
|
|
command.upgrade(_alembic_config(db), "head")
|
|
|
|
rows = _rows(db, "Sommar")
|
|
assert [r["first_year"] for r in rows] == [2026, 2027, 2028, 2029]
|
|
|
|
# One series, and the original row survives as the first year's occurrence
|
|
# (so the `planned_leave_day.wish_id` rows pointing at it stay attached).
|
|
series_ids = {r["series_id"] for r in rows}
|
|
assert len(series_ids) == 1
|
|
assert None not in series_ids
|
|
assert rows[0]["id"] == anchor_id
|
|
|
|
# The weekday snap is now IN the rows: each year keeps Monday->Sunday
|
|
# rather than drifting to the naive 10-05 (a Tuesday in 2027).
|
|
stored = [(r["window_start_month"], r["window_start_day"]) for r in rows]
|
|
assert stored == [(10, 5), (10, 4), (10, 2), (10, 8)]
|
|
for row in rows:
|
|
start = date(row["first_year"], row["window_start_month"], row["window_start_day"])
|
|
end = date(row["first_year"], row["window_end_month"], row["window_end_day"])
|
|
assert start.weekday() == 0, row["first_year"]
|
|
assert end.weekday() == 6, row["first_year"]
|
|
assert (end - start).days == 6
|
|
|
|
# Series-level intent is mirrored onto every row.
|
|
assert {r["last_year"] for r in rows} == {2029}
|
|
# And the dropped `recurrence` column really is gone.
|
|
assert "recurrence" not in rows[0].keys()
|
|
|
|
|
|
def test_open_ended_yearly_wish_stops_at_the_childs_fourth_birthday_year(tmp_path: Path) -> None:
|
|
db = tmp_path / "age4.db"
|
|
command.upgrade(_alembic_config(db), "003")
|
|
|
|
_insert_pre_004_fixture(
|
|
db,
|
|
child_birth_date=date(2026, 5, 1), # 4th birthday in 2030
|
|
wishes=[
|
|
{
|
|
"id": str(uuid.uuid4()),
|
|
"name": "Jul",
|
|
"wsm": 12,
|
|
"wsd": 20,
|
|
"wem": 1,
|
|
"wed": 6, # wraps into January
|
|
"target_weeks": 3,
|
|
"recurrence": "yearly",
|
|
"first_year": 2026,
|
|
"last_year": None, # "until the child turns 4"
|
|
"window_mode": "fixed",
|
|
}
|
|
],
|
|
)
|
|
|
|
command.upgrade(_alembic_config(db), "head")
|
|
|
|
rows = _rows(db, "Jul")
|
|
assert [r["first_year"] for r in rows] == [2026, 2027, 2028, 2029, 2030]
|
|
assert {r["last_year"] for r in rows} == {None}
|
|
# Still a wrapping window on every row: end sorts before start.
|
|
for row in rows:
|
|
assert (row["window_end_month"], row["window_end_day"]) < (
|
|
row["window_start_month"],
|
|
row["window_start_day"],
|
|
)
|
|
|
|
|
|
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")
|
|
|
|
_insert_pre_004_fixture(
|
|
db,
|
|
child_birth_date=date(2026, 5, 1),
|
|
wishes=[
|
|
{
|
|
"id": str(uuid.uuid4()),
|
|
"name": "Var",
|
|
"wsm": 3,
|
|
"wsd": 1, # 2027-03-01 is a Monday
|
|
"wem": 5,
|
|
"wed": 31,
|
|
"target_weeks": 1,
|
|
"recurrence": "yearly",
|
|
"first_year": 2027,
|
|
"last_year": 2029,
|
|
"window_mode": "flexible",
|
|
}
|
|
],
|
|
)
|
|
|
|
command.upgrade(_alembic_config(db), "head")
|
|
|
|
rows = _rows(db, "Var")
|
|
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
|
|
# 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:
|
|
db = tmp_path / "once.db"
|
|
command.upgrade(_alembic_config(db), "003")
|
|
|
|
_insert_pre_004_fixture(
|
|
db,
|
|
child_birth_date=date(2026, 5, 1),
|
|
wishes=[
|
|
{
|
|
"id": str(uuid.uuid4()),
|
|
"name": "Engang",
|
|
"wsm": 4,
|
|
"wsd": 1,
|
|
"wem": 4,
|
|
"wed": 7,
|
|
"target_weeks": 1,
|
|
"recurrence": "once",
|
|
"first_year": 2027,
|
|
"last_year": None,
|
|
"window_mode": "fixed",
|
|
}
|
|
],
|
|
)
|
|
|
|
command.upgrade(_alembic_config(db), "head")
|
|
|
|
rows = _rows(db, "Engang")
|
|
assert len(rows) == 1
|
|
assert rows[0]["series_id"] is None
|
|
assert (rows[0]["window_start_month"], rows[0]["window_start_day"]) == (4, 1)
|
|
|
|
|
|
def test_downgrade_collapses_a_series_back_to_its_first_year(tmp_path: Path) -> None:
|
|
db = tmp_path / "roundtrip.db"
|
|
command.upgrade(_alembic_config(db), "003")
|
|
|
|
anchor_id = str(uuid.uuid4())
|
|
_insert_pre_004_fixture(
|
|
db,
|
|
child_birth_date=date(2026, 5, 1),
|
|
wishes=[
|
|
{
|
|
"id": anchor_id,
|
|
"name": "Sommar",
|
|
"wsm": 6,
|
|
"wsd": 15,
|
|
"wem": 7,
|
|
"wed": 26,
|
|
"target_weeks": 6,
|
|
"recurrence": "yearly",
|
|
"first_year": 2026,
|
|
"last_year": 2029,
|
|
"window_mode": "fixed",
|
|
}
|
|
],
|
|
)
|
|
|
|
command.upgrade(_alembic_config(db), "head")
|
|
assert len(_rows(db, "Sommar")) == 4
|
|
|
|
command.downgrade(_alembic_config(db), "003")
|
|
|
|
rows = _rows(db, "Sommar")
|
|
assert len(rows) == 1
|
|
assert rows[0]["id"] == anchor_id
|
|
assert rows[0]["recurrence"] == "yearly"
|
|
assert rows[0]["first_year"] == 2026
|
|
assert (rows[0]["window_start_month"], rows[0]["window_start_day"]) == (6, 15)
|