Give every year of a repeating period its own row
A repeating period was a single row that the planner expanded into one window per year at read time, so no year had anywhere to differ from the others. Editing one year rewrote them all, and removing a year in the middle had to split the wish in two. Neither survives contact with the thing this app is for: Easter moves, and the cheapest week is not the same week every year. Each year is now its own row, linked by a series id. Editing one year touches one row; editing the series re-projects the rest from it. Removing a year is one delete. The weekday snap is worked out once, when the rows are made, and stored, so a row now says what it means rather than being re-derived on every read. Recurrence stops being a stored field and becomes what it always was in practice — an instruction at create and update time; whether a period repeats is whether it belongs to a series. Migration 004 rebuilds the table and expands the existing rows, keeping each original row as its first year so the days already planned against it still point somewhere. A Feb 29 anchor recurring into a common year used to raise out of the allocator; it clamps now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
"""Materialise repeating leave wishes into one row per year, grouped by series_id.
|
||||
|
||||
A repeating wish used to be ONE row (`recurrence = 'yearly'` plus
|
||||
`first_year`/`last_year`), and every year's window was re-derived at read time
|
||||
by `LeaveWish.window_for_year()`. That made a single year impossible to edit
|
||||
(there was no row for it) and a single year impossible to remove without
|
||||
narrowing the range or splitting the wish in two. It also left nowhere to put
|
||||
a per-year offset — which the planner genuinely needs, because Easter moves.
|
||||
|
||||
This migration therefore:
|
||||
|
||||
1. drops the `recurrence` column and adds a nullable, indexed `series_id`
|
||||
(SQLite has no `DROP CONSTRAINT`, and the column carries a CHECK, so the
|
||||
table is rebuilt and its indexes recreated — the standard SQLite dance,
|
||||
see https://sqlite.org/lang_altertable.html#otheralter and migration 002);
|
||||
2. backfills: every `recurrence = 'yearly'` row is expanded into one row per
|
||||
year, sharing a freshly minted `series_id`. Each new row stores the exact
|
||||
dates the OLD `window_for_year(year)` would have produced for that year,
|
||||
*including* the fixed-mode weekday snap added in 224fe7a — the projection
|
||||
is copied verbatim below rather than imported from `app.entities`, so this
|
||||
migration keeps describing the 2026-09 behaviour even after that module
|
||||
moves on.
|
||||
|
||||
The original row is kept (same id, so its `planned_leave_day.wish_id`
|
||||
references survive) and becomes the series' `first_year` occurrence, whose
|
||||
window is by definition unshifted.
|
||||
|
||||
`last_year` survives as the SERIES-level intent, mirrored onto every row of a
|
||||
series (NULL = "until the child turns 4"); standalone rows get NULL.
|
||||
|
||||
Must stay byte-for-byte equivalent to the `leave_wish` table in
|
||||
`app/db/connection.py`'s `_FULL_SCHEMA` —
|
||||
`tests/integration/test_schema_parity.py` asserts exactly that.
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2026-09-19
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "004"
|
||||
down_revision: str | None = "003"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
# How far a `last_year IS NULL` ("until the child turns 4") series is expanded
|
||||
# when nothing else bounds it. Mirrors the allocator's own childless fallback
|
||||
# horizon (`_compute_horizon_end`: today.year + 4) and the cached Swedish
|
||||
# calendar's upper bound (`swedish_calendar.json` covers 2026-2055; planning
|
||||
# past it raises). Hardcoded rather than imported: a migration is a snapshot.
|
||||
_CHILDLESS_YEARS_AHEAD = 4
|
||||
_MAX_MATERIALISED_YEAR = 2055
|
||||
_MAX_SERIES_LENGTH_YEARS = 30
|
||||
|
||||
_UPGRADE_DDL = """
|
||||
CREATE TABLE leave_wish_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
child_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
name_message_key TEXT,
|
||||
window_start_month INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_start_month >= 1 AND window_start_month <= 12),
|
||||
window_start_day INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_start_day >= 1 AND window_start_day <= 31),
|
||||
window_end_month INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_end_month >= 1 AND window_end_month <= 12),
|
||||
window_end_day INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_end_day >= 1 AND window_end_day <= 31),
|
||||
target_weeks INTEGER NOT NULL,
|
||||
target_days INTEGER,
|
||||
first_year INTEGER NOT NULL DEFAULT 2026,
|
||||
last_year INTEGER,
|
||||
priority INTEGER NOT NULL DEFAULT 1 CHECK (priority >= 1),
|
||||
parent_mode TEXT NOT NULL DEFAULT 'either'
|
||||
CHECK (parent_mode IN ('either','both','specific')),
|
||||
parent_personal_data_uuid TEXT,
|
||||
window_mode TEXT NOT NULL DEFAULT 'fixed'
|
||||
CHECK (window_mode IN ('fixed','flexible')),
|
||||
series_id TEXT,
|
||||
wants_dubbeldag INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO leave_wish_new (
|
||||
id, workspace_id, child_id, name, name_message_key,
|
||||
window_start_month, window_start_day, window_end_month, window_end_day,
|
||||
target_weeks, target_days, first_year, last_year,
|
||||
priority, parent_mode, parent_personal_data_uuid,
|
||||
window_mode, series_id, wants_dubbeldag, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, workspace_id, child_id, name, name_message_key,
|
||||
window_start_month, window_start_day, window_end_month, window_end_day,
|
||||
target_weeks, target_days, first_year, last_year,
|
||||
priority, parent_mode, parent_personal_data_uuid,
|
||||
window_mode, NULL, wants_dubbeldag, created_at, updated_at
|
||||
FROM leave_wish;
|
||||
|
||||
DROP TABLE leave_wish;
|
||||
|
||||
ALTER TABLE leave_wish_new RENAME TO leave_wish;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_leave_wish_workspace
|
||||
ON leave_wish (workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_leave_wish_child
|
||||
ON leave_wish (child_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_leave_wish_parent
|
||||
ON leave_wish (parent_personal_data_uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_leave_wish_series
|
||||
ON leave_wish (series_id);
|
||||
"""
|
||||
|
||||
_DOWNGRADE_DDL = """
|
||||
CREATE TABLE leave_wish_old (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
child_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
name_message_key TEXT,
|
||||
window_start_month INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_start_month >= 1 AND window_start_month <= 12),
|
||||
window_start_day INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_start_day >= 1 AND window_start_day <= 31),
|
||||
window_end_month INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_end_month >= 1 AND window_end_month <= 12),
|
||||
window_end_day INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_end_day >= 1 AND window_end_day <= 31),
|
||||
target_weeks INTEGER NOT NULL,
|
||||
target_days INTEGER,
|
||||
recurrence TEXT NOT NULL DEFAULT 'once'
|
||||
CHECK (recurrence IN ('yearly','once')),
|
||||
first_year INTEGER NOT NULL DEFAULT 2026,
|
||||
last_year INTEGER,
|
||||
priority INTEGER NOT NULL DEFAULT 1 CHECK (priority >= 1),
|
||||
parent_mode TEXT NOT NULL DEFAULT 'either'
|
||||
CHECK (parent_mode IN ('either','both','specific')),
|
||||
parent_personal_data_uuid TEXT,
|
||||
window_mode TEXT NOT NULL DEFAULT 'fixed'
|
||||
CHECK (window_mode IN ('fixed','flexible')),
|
||||
wants_dubbeldag INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO leave_wish_old (
|
||||
id, workspace_id, child_id, name, name_message_key,
|
||||
window_start_month, window_start_day, window_end_month, window_end_day,
|
||||
target_weeks, target_days, recurrence, first_year, last_year,
|
||||
priority, parent_mode, parent_personal_data_uuid,
|
||||
window_mode, wants_dubbeldag, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, workspace_id, child_id, name, name_message_key,
|
||||
window_start_month, window_start_day, window_end_month, window_end_day,
|
||||
target_weeks, target_days,
|
||||
CASE WHEN series_id IS NULL THEN 'once' ELSE 'yearly' END,
|
||||
first_year, last_year,
|
||||
priority, parent_mode, parent_personal_data_uuid,
|
||||
window_mode, wants_dubbeldag, created_at, updated_at
|
||||
FROM leave_wish;
|
||||
|
||||
DROP TABLE leave_wish;
|
||||
|
||||
ALTER TABLE leave_wish_old RENAME TO leave_wish;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_leave_wish_workspace
|
||||
ON leave_wish (workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_leave_wish_child
|
||||
ON leave_wish (child_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_leave_wish_parent
|
||||
ON leave_wish (parent_personal_data_uuid);
|
||||
"""
|
||||
|
||||
|
||||
def _execute_script(sql: str) -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
for stmt in [s.strip() for s in sql.split(";") if s.strip()]:
|
||||
op.execute(text(stmt))
|
||||
|
||||
|
||||
# --- verbatim copy of the pre-004 window projection -------------------------
|
||||
# Kept local on purpose: the backfill must reproduce what `window_for_year()`
|
||||
# produced on 2026-09-19, not whatever `app.entities.leave_wish` does later.
|
||||
|
||||
|
||||
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(row: Any, year: int) -> tuple[dt.date, dt.date]:
|
||||
sm, sd = row.window_start_month, row.window_start_day
|
||||
em, ed = row.window_end_month, row.window_end_day
|
||||
wraps = (em, ed) < (sm, sd)
|
||||
naive_start = _clamped(year, sm, sd)
|
||||
naive_end = _clamped(year + 1 if wraps else year, em, ed)
|
||||
if row.window_mode != "fixed" or year == row.first_year:
|
||||
return naive_start, naive_end
|
||||
anchor_start = _clamped(row.first_year, sm, sd)
|
||||
anchor_end = _clamped(row.first_year + 1 if wraps else row.first_year, em, ed)
|
||||
start = _shift_to_weekday(naive_start, anchor_start.weekday())
|
||||
return start, start + (anchor_end - anchor_start)
|
||||
|
||||
|
||||
def _series_end_year(row: Any, birth_years: dict[str, int], today_year: int) -> int:
|
||||
if row.last_year is not None:
|
||||
end = int(row.last_year)
|
||||
elif row.child_id is not None and row.child_id in birth_years:
|
||||
end = birth_years[row.child_id] + 4
|
||||
else:
|
||||
end = today_year + _CHILDLESS_YEARS_AHEAD
|
||||
end = min(end, _MAX_MATERIALISED_YEAR, row.first_year + _MAX_SERIES_LENGTH_YEARS)
|
||||
return max(end, int(row.first_year))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
yearly = conn.execute(
|
||||
text(
|
||||
"SELECT id, workspace_id, child_id, name, name_message_key, "
|
||||
"window_start_month, window_start_day, window_end_month, window_end_day, "
|
||||
"target_weeks, target_days, first_year, last_year, priority, parent_mode, "
|
||||
"parent_personal_data_uuid, window_mode, wants_dubbeldag, created_at, updated_at "
|
||||
"FROM leave_wish WHERE recurrence = 'yearly'"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
birth_years: dict[str, int] = {}
|
||||
for child_id, birth_date in conn.execute(text("SELECT id, birth_date FROM child")).fetchall():
|
||||
if birth_date is None:
|
||||
continue
|
||||
birth_years[str(child_id)] = int(str(birth_date)[:4])
|
||||
|
||||
today_year = dt.date.today().year
|
||||
plan: list[dict[str, Any]] = []
|
||||
for row in yearly:
|
||||
series_id = str(uuid.uuid4())
|
||||
end_year = _series_end_year(row, birth_years, today_year)
|
||||
for year in range(int(row.first_year), end_year + 1):
|
||||
start, end = _project(row, year)
|
||||
plan.append(
|
||||
{
|
||||
"id": str(row.id) if year == int(row.first_year) else str(uuid.uuid4()),
|
||||
"is_anchor": year == int(row.first_year),
|
||||
"source_id": str(row.id),
|
||||
"series_id": series_id,
|
||||
"first_year": year,
|
||||
"window_start_month": start.month,
|
||||
"window_start_day": start.day,
|
||||
"window_end_month": end.month,
|
||||
"window_end_day": end.day,
|
||||
}
|
||||
)
|
||||
|
||||
_execute_script(_UPGRADE_DDL)
|
||||
|
||||
for item in plan:
|
||||
if item["is_anchor"]:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE leave_wish SET series_id = :series_id, "
|
||||
"window_start_month = :wsm, window_start_day = :wsd, "
|
||||
"window_end_month = :wem, window_end_day = :wed "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"series_id": item["series_id"],
|
||||
"wsm": item["window_start_month"],
|
||||
"wsd": item["window_start_day"],
|
||||
"wem": item["window_end_month"],
|
||||
"wed": item["window_end_day"],
|
||||
"id": item["id"],
|
||||
},
|
||||
)
|
||||
continue
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO leave_wish ("
|
||||
" id, workspace_id, child_id, name, name_message_key,"
|
||||
" window_start_month, window_start_day, window_end_month, window_end_day,"
|
||||
" target_weeks, target_days, first_year, last_year,"
|
||||
" priority, parent_mode, parent_personal_data_uuid,"
|
||||
" window_mode, series_id, wants_dubbeldag, created_at, updated_at)"
|
||||
" SELECT :id, workspace_id, child_id, name, name_message_key,"
|
||||
" :wsm, :wsd, :wem, :wed,"
|
||||
" target_weeks, target_days, :first_year, last_year,"
|
||||
" priority, parent_mode, parent_personal_data_uuid,"
|
||||
" window_mode, :series_id, wants_dubbeldag, created_at, updated_at"
|
||||
" FROM leave_wish WHERE id = :source_id"
|
||||
),
|
||||
{
|
||||
"id": item["id"],
|
||||
"wsm": item["window_start_month"],
|
||||
"wsd": item["window_start_day"],
|
||||
"wem": item["window_end_month"],
|
||||
"wed": item["window_end_day"],
|
||||
"first_year": item["first_year"],
|
||||
"series_id": item["series_id"],
|
||||
"source_id": item["source_id"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
conn = op.get_bind()
|
||||
# Collapse each series back to its earliest occurrence, which is the row
|
||||
# the pre-004 shape would have stored (`window_for_year(first_year)` is
|
||||
# unshifted). The later occurrences' individual edits cannot survive a
|
||||
# shape that has nowhere to keep them.
|
||||
conn.execute(
|
||||
text(
|
||||
"DELETE FROM leave_wish WHERE series_id IS NOT NULL AND first_year > ("
|
||||
" SELECT MIN(inner_lw.first_year) FROM leave_wish AS inner_lw"
|
||||
" WHERE inner_lw.series_id = leave_wish.series_id)"
|
||||
)
|
||||
)
|
||||
_execute_script(_DOWNGRADE_DDL)
|
||||
Reference in New Issue
Block a user