Files
F-r-ldraledigplannerare/backend/migrations/versions/008_realign_flexible_series.py
T
jacobh2 ad0e3c0b33 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
2026-09-20 12:04:20 +02:00

147 lines
5.6 KiB
Python

"""Realign already-materialised flexible series onto their anchor's weekday.
`project_window` (`app/entities/leave_wish.py`) used to skip the weekday snap
for `window_mode == 'flexible'` rows — every year of a flexible series stored
the identical naive month/day bound, so a week-shaped bound (e.g. "the week
of Oct 5") silently drifted onto a different weekday every year it wasn't the
anchor. That bug is fixed at the source (`project_window` now snaps a
flexible bound's start to the anchor year's start weekday too, holding the
bound's day-span fixed, exactly like `window_mode == 'fixed'` already did),
but `project_window` only ever 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, here.
This migration:
1. finds every `leave_wish` row with `window_mode = 'flexible'` and a
non-null `series_id`;
2. groups them by `series_id`, and for each series treats the row with the
lowest `first_year` as the anchor occurrence (unshifted by definition —
`project_window` never shifts `year == anchor_year`), the same convention
004's `downgrade()` uses to collapse a series back to one row;
3. for every OTHER row in that series, re-projects that year's bound from
the anchor's own stored month/day fields (which, being the anchor, are
already the naive/unshifted pattern) using the anchor's start weekday,
and rewrites that row's four month/day columns in place.
Fixed-mode series are left untouched — `project_window` already snapped them
correctly the day they were materialised, and re-running the (identical)
projection on them would be a costly no-op at best.
No schema change: this is a pure data rewrite. The projection helpers below
are a **self-contained copy**, not an import of `app.entities.leave_wish`,
for the same reason 004 gives at its line 196 — a migration must keep
describing the behaviour it was written against, not whatever the entity
module does later.
`downgrade()` restores the naive (unsnapped) anchor month/day for every
non-anchor flexible row — i.e. exactly what this migration found — so the
round trip is lossless for anchor rows (never touched) and reverses cleanly
for every row it did touch.
Revision ID: 008
Revises: 007
Create Date: 2026-09-20
"""
from __future__ import annotations
import calendar
import datetime as dt
from typing import Any
from alembic import op
revision: str = "008"
down_revision: str | None = "007"
branch_labels: str | None = None
depends_on: str | None = None
def _clamped(year: int, month: int, day: int) -> dt.date:
return dt.date(year, month, min(day, calendar.monthrange(year, month)[1]))
def _shift_to_weekday(naive: dt.date, target_weekday: int) -> dt.date:
forward = (target_weekday - naive.weekday()) % 7
shift = min((forward, forward - 7), key=lambda d: (abs(d), d))
return naive + dt.timedelta(days=shift)
def _project(
sm: int, sd: int, em: int, ed: int, anchor_year: int, year: int, *, snap: bool
) -> tuple[dt.date, dt.date]:
"""Verbatim copy of `project_window`'s post-fix (`snap=True`) and
pre-fix (`snap=False`) behaviour, parametrised on the anchor row's own
(start_month, start_day, end_month, end_day) pattern rather than a
`LeaveWish` instance.
"""
wraps = (em, ed) < (sm, sd)
naive_start = _clamped(year, sm, sd)
naive_end = _clamped(year + 1 if wraps else year, em, ed)
if not snap or year == anchor_year:
return naive_start, naive_end
anchor_start = _clamped(anchor_year, sm, sd)
anchor_end = _clamped(anchor_year + 1 if wraps else anchor_year, em, ed)
span = anchor_end - anchor_start
start = _shift_to_weekday(naive_start, anchor_start.weekday())
return start, start + span
def _flexible_series_rows(conn: Any) -> dict[str, list[Any]]:
from sqlalchemy import text
rows = conn.execute(
text(
"SELECT id, series_id, first_year, "
"window_start_month, window_start_day, window_end_month, window_end_day "
"FROM leave_wish "
"WHERE window_mode = 'flexible' AND series_id IS NOT NULL "
"ORDER BY series_id, first_year"
)
).fetchall()
by_series: dict[str, list[Any]] = {}
for row in rows:
by_series.setdefault(str(row.series_id), []).append(row)
return by_series
def _rewrite(conn: Any, *, snap: bool) -> None:
from sqlalchemy import text
for series_rows in _flexible_series_rows(conn).values():
anchor = min(series_rows, key=lambda r: int(r.first_year))
sm, sd = anchor.window_start_month, anchor.window_start_day
em, ed = anchor.window_end_month, anchor.window_end_day
anchor_year = int(anchor.first_year)
for row in series_rows:
year = int(row.first_year)
if year == anchor_year:
continue
start, end = _project(sm, sd, em, ed, anchor_year, year, snap=snap)
conn.execute(
text(
"UPDATE leave_wish SET "
"window_start_month = :wsm, window_start_day = :wsd, "
"window_end_month = :wem, window_end_day = :wed "
"WHERE id = :id"
),
{
"wsm": start.month,
"wsd": start.day,
"wem": end.month,
"wed": end.day,
"id": row.id,
},
)
def upgrade() -> None:
_rewrite(op.get_bind(), snap=True)
def downgrade() -> None:
_rewrite(op.get_bind(), snap=False)