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
189 lines
7.0 KiB
Python
189 lines
7.0 KiB
Python
"""Migration 008's data rewrite: realign already-materialised flexible series.
|
|
|
|
`project_window` (`app/entities/leave_wish.py`) now snaps a `window_mode ==
|
|
'flexible'` bound's start to the anchor year's start weekday too (Step 1 of
|
|
the flexible-window realignment fix), holding the bound's day-span fixed —
|
|
mirroring what `window_mode == 'fixed'` already did. But `project_window`
|
|
only runs once, at materialisation time (`LeaveWishService`); nothing
|
|
re-derives it at read time. Every flexible series row materialised before
|
|
this fix therefore stays unsnapped forever unless something rewrites it
|
|
once. Migration 008 is that one-time rewrite.
|
|
|
|
Driven through the real Alembic chain (upgrade to 007, insert POST-004-shape
|
|
rows directly — the one-row-per-year shape already exists by 007, so there is
|
|
no pre-004 fixture to build here — then upgrade to head/008), mirroring
|
|
`test_migration_004_backfill.py`'s style.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import uuid
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
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_post_004_fixture(db_path: Path, *, rows: list[dict[str, object]]) -> str:
|
|
"""Seed a workspace and the given POST-004-shape `leave_wish` rows.
|
|
|
|
`rows` are full leave_wish rows (one per year already) — the shape 004
|
|
produces and 007 leaves untouched — grouped by `series_id`.
|
|
"""
|
|
now = datetime(2026, 1, 1, 12, 0, 0).isoformat()
|
|
ws_id = str(uuid.uuid4())
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)",
|
|
(ws_id, "WS", now),
|
|
)
|
|
for row in rows:
|
|
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, first_year, last_year, priority, parent_mode, "
|
|
"window_mode, series_id, created_at, updated_at) "
|
|
"VALUES (:id, :workspace_id, NULL, :name, :wsm, :wsd, :wem, :wed, "
|
|
":target_weeks, :first_year, :last_year, 1, 'either', "
|
|
":window_mode, :series_id, :now, :now)",
|
|
{"workspace_id": ws_id, "now": now, **row},
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return ws_id
|
|
|
|
|
|
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 _flexible_series(
|
|
*, name: str, series_id: str, first_year: int, last_year: int, wsm: int, wsd: int
|
|
) -> list[dict[str, Any]]:
|
|
"""Rows in 004's shape for a flexible series whose bound was NAIVELY
|
|
|
|
materialised (every year the identical month/day pair) — exactly what
|
|
every flexible series looked like before this fix, and what 008 must
|
|
rewrite.
|
|
"""
|
|
return [
|
|
{
|
|
"id": str(uuid.uuid4()),
|
|
"name": name,
|
|
"wsm": wsm,
|
|
"wsd": wsd,
|
|
"wem": 5,
|
|
"wed": 31,
|
|
"target_weeks": 1,
|
|
"first_year": year,
|
|
"last_year": last_year,
|
|
"window_mode": "flexible",
|
|
"series_id": series_id,
|
|
}
|
|
for year in range(first_year, last_year + 1)
|
|
]
|
|
|
|
|
|
def test_upgrade_realigns_a_flexible_series_onto_its_anchor_weekday(tmp_path: Path) -> None:
|
|
db = tmp_path / "realign.db"
|
|
command.upgrade(_alembic_config(db), "007")
|
|
|
|
series_id = str(uuid.uuid4())
|
|
# 2027-03-01 is a Monday; naively every year got the same (3, 1).
|
|
rows_in = _flexible_series(
|
|
name="Var", series_id=series_id, first_year=2027, last_year=2029, wsm=3, wsd=1
|
|
)
|
|
_insert_post_004_fixture(db, rows=rows_in)
|
|
|
|
command.upgrade(_alembic_config(db), "head")
|
|
|
|
rows = _rows(db, "Var")
|
|
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
|
|
# Anchor (2027) untouched; 2028/2029 snapped to Monday, span held fixed.
|
|
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"])
|
|
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_downgrade_restores_the_naive_anchor_month_day(tmp_path: Path) -> None:
|
|
db = tmp_path / "realign_roundtrip.db"
|
|
command.upgrade(_alembic_config(db), "007")
|
|
|
|
series_id = str(uuid.uuid4())
|
|
rows_in = _flexible_series(
|
|
name="Var", series_id=series_id, first_year=2027, last_year=2029, wsm=3, wsd=1
|
|
)
|
|
_insert_post_004_fixture(db, rows=rows_in)
|
|
|
|
command.upgrade(_alembic_config(db), "head")
|
|
assert {(r["window_start_month"], r["window_start_day"]) for r in _rows(db, "Var")} != {(3, 1)}
|
|
|
|
command.downgrade(_alembic_config(db), "007")
|
|
|
|
rows = _rows(db, "Var")
|
|
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
|
|
assert {(r["window_start_month"], r["window_start_day"]) for r in rows} == {(3, 1)}
|
|
assert {(r["window_end_month"], r["window_end_day"]) for r in rows} == {(5, 31)}
|
|
|
|
|
|
def test_fixed_series_is_left_untouched(tmp_path: Path) -> None:
|
|
"""`project_window` already snapped a fixed series correctly at 004's
|
|
|
|
materialisation time — 008 must be a no-op for it, not a costly
|
|
re-run of an already-correct projection.
|
|
"""
|
|
db = tmp_path / "fixed_untouched.db"
|
|
command.upgrade(_alembic_config(db), "007")
|
|
|
|
series_id = str(uuid.uuid4())
|
|
rows_in = [
|
|
{
|
|
"id": str(uuid.uuid4()),
|
|
"name": "Sommar",
|
|
"wsm": 10,
|
|
"wsd": 5 if year == 2026 else (4 if year == 2027 else 2),
|
|
"wem": 10,
|
|
"wed": 11 if year == 2026 else (10 if year == 2027 else 8),
|
|
"target_weeks": 1,
|
|
"first_year": year,
|
|
"last_year": 2028,
|
|
"window_mode": "fixed",
|
|
"series_id": series_id,
|
|
}
|
|
for year in (2026, 2027, 2028)
|
|
]
|
|
_insert_post_004_fixture(db, rows=rows_in)
|
|
|
|
before = {r["first_year"]: dict(r) for r in _rows(db, "Sommar")}
|
|
command.upgrade(_alembic_config(db), "head")
|
|
after = {r["first_year"]: dict(r) for r in _rows(db, "Sommar")}
|
|
|
|
for year, row in before.items():
|
|
assert after[year]["window_start_month"] == row["window_start_month"]
|
|
assert after[year]["window_start_day"] == row["window_start_day"]
|
|
assert after[year]["window_end_month"] == row["window_end_month"]
|
|
assert after[year]["window_end_day"] == row["window_end_day"]
|