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
147 lines
5.6 KiB
Python
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)
|