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:
2026-09-19 15:53:25 +02:00
parent c6593062e3
commit 6d92b87bf3
43 changed files with 2735 additions and 638 deletions
@@ -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)