From 6d92b87bf33fd32c5f9ab27c0b51eba4ba99ff07 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 19 Sep 2026 15:53:25 +0200 Subject: [PATCH] Give every year of a repeating period its own row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep --- backend/app/db/connection.py | 5 +- backend/app/dependencies.py | 5 +- backend/app/entities/leave_wish.py | 196 ++++--- backend/app/models/leave_wish.py | 33 +- .../app/repositories/leave_wish_repository.py | 9 + .../sqlite/leave_wish_repository.py | 29 +- backend/app/routers/leave_wishes.py | 36 +- backend/app/services/allocation_service.py | 127 ++--- backend/app/services/leave_wish_service.py | 309 +++++++++- backend/app/services/tips_service.py | 22 +- .../versions/004_leave_wish_series.py | 344 +++++++++++ backend/scripts/demo_plan.py | 78 +-- backend/scripts/seed_demo.py | 2 +- backend/tests/conftest.py | 5 +- .../test_migration_004_backfill.py | 285 ++++++++++ .../tests/integration/test_user_journey.py | 15 +- backend/tests/unit/test_authz.py | 2 - backend/tests/unit/test_db_unit_of_work.py | 1 - .../tests/unit/test_leave_wish_repository.py | 536 ++++++++++++++++-- backend/tests/unit/test_leave_wish_router.py | 121 ++++ backend/tests/unit/test_planning_service.py | 1 - .../tests/unit/test_planning_service_apply.py | 1 - .../tests/unit/test_planning_service_sync.py | 1 - .../test_allocation_input_loader.py | 15 +- .../test_allocation_once_and_wrap.py | 13 +- .../test_services/test_allocation_service.py | 216 ++++--- .../test_allocation_swap_fairness.py | 1 - .../unit/test_services/test_tips_service.py | 10 +- backend/tests/unit/test_workspace_service.py | 1 - frontend/e2e/tests/drag-create-delete.spec.ts | 4 +- .../calendar/PeriodPopover.test.tsx | 69 ++- .../src/components/calendar/PeriodPopover.tsx | 125 ++-- frontend/src/hooks/useLeaveWishes.test.ts | 38 +- frontend/src/hooks/useLeaveWishes.ts | 22 +- frontend/src/i18n/en/calendar.ts | 10 + frontend/src/i18n/sv/calendar.ts | 11 + frontend/src/pages/Calendar.test.tsx | 322 +++++++---- frontend/src/pages/Calendar.tsx | 200 +++---- frontend/src/services/api.test.ts | 98 +++- frontend/src/services/api.ts | 21 +- frontend/src/types/api.ts | 20 + frontend/src/types/entities.ts | 10 +- frontend/tests/fixtures/mockData.ts | 4 +- 43 files changed, 2735 insertions(+), 638 deletions(-) create mode 100644 backend/migrations/versions/004_leave_wish_series.py create mode 100644 backend/tests/integration/test_migration_004_backfill.py diff --git a/backend/app/db/connection.py b/backend/app/db/connection.py index 8a537c7..414579c 100644 --- a/backend/app/db/connection.py +++ b/backend/app/db/connection.py @@ -96,8 +96,6 @@ CREATE TABLE IF NOT EXISTS leave_wish ( 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), @@ -106,6 +104,7 @@ CREATE TABLE IF NOT EXISTS leave_wish ( 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, @@ -214,6 +213,8 @@ 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); CREATE INDEX IF NOT EXISTS idx_child_workspace_birth ON child (workspace_id, birth_date); CREATE INDEX IF NOT EXISTS idx_personal_data_workspace_deleted diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 79e5c6f..8378afb 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -229,8 +229,11 @@ VacationDaysServiceDep = Annotated[VacationDaysService, Depends(get_vacation_day def get_leave_wish_service( repo: LeaveWishRepositoryDep, planned_leave_repo: PlannedLeaveRepositoryDep, + child_repo: ChildRepositoryDep, ) -> LeaveWishService: - return LeaveWishService(repo, planned_leave_repo) + # `child_repo` resolves "until the child turns 4" into a concrete last + # year when a repeating wish is materialised — see LeaveWishService. + return LeaveWishService(repo, planned_leave_repo, child_repo) LeaveWishServiceDep = Annotated[LeaveWishService, Depends(get_leave_wish_service)] diff --git a/backend/app/entities/leave_wish.py b/backend/app/entities/leave_wish.py index 84b6f88..fdeae62 100644 --- a/backend/app/entities/leave_wish.py +++ b/backend/app/entities/leave_wish.py @@ -1,17 +1,29 @@ """Domain entity: LeaveWish. -A family's recurring intent to be home for N weeks inside a yearly window -(e.g. "6 weeks every summer until the child turns 4"). Renamed and extended -from `BreakPeriod` (see PLANNING_ENGINE_SPEC.md §2.1): the old entity was -single-year with no notion of recurrence, priority, or which parent. This is -the input side of the planning engine's allocator (§3); the allocator itself -is Phase B. +A family's intent to be home for N weeks inside a window of ONE year +(e.g. "6 weeks in the summer of 2028"). Renamed and extended from +`BreakPeriod` (see PLANNING_ENGINE_SPEC.md §2.1). -The recurring window is stored as month/day integers (not dates) so it can -repeat yearly without a custom SQLite type. If the window's end sorts before -its start, it wraps into the next year — e.g. Jullov 12-20 -> 01-07. +**One row = one occurrence.** A wish the user asked to repeat every year is +*materialised* by `LeaveWishService` into one row per year, all sharing a +`series_id`; a genuinely one-off wish has `series_id = None`. There is no +`recurrence` field any more — a row either stands alone or belongs to a +series, and either way it describes exactly one year's window. + +That materialisation is what makes per-year divergence possible: Easter (and +therefore the planner's "shift this window onto the red days" advice) moves +from year to year, so every year needs its own editable row to carry its own +offset. Editing one year touches one row; editing "every year" re-projects +the whole series from the edited row. + +The window is still stored as month/day integers plus `first_year` (the year +the window starts in) rather than two dates — that is the existing column +layout, and it keeps the wrap rule ("end sorts before start" => the end lands +in `first_year + 1`, e.g. Jullov 12-20 -> 01-07) expressible without a custom +SQLite type. """ +import calendar from dataclasses import dataclass from datetime import date, datetime, timedelta from uuid import UUID @@ -32,25 +44,93 @@ def _shift_to_weekday(naive: date, target_weekday: int) -> date: return naive + timedelta(days=shift) +def clamped_date(year: int, month: int, day: int) -> date: + """`date(year, month, day)`, with `day` clamped to the month's real length. + + Only Feb 29 in a non-leap year can actually trigger the clamp for a + stored window (the service's `_validate_month_day` already rejects + month/day pairs that are not a real date in a leap year). A wish anchored + on Feb 29 used to raise `ValueError` out of the allocator the first time + it recurred into a non-leap year; clamping to Feb 28 is the only sane + answer for a whole-day planner and never changes any other date. + """ + return date(year, month, min(day, calendar.monthrange(year, month)[1])) + + +def window_wraps_month_day(start_month: int, start_day: int, end_month: int, end_day: int) -> bool: + """Whether (end_month, end_day) sorts before (start_month, start_day).""" + return (end_month, end_day) < (start_month, start_day) + + +def project_window( + *, + start_month: int, + start_day: int, + end_month: int, + end_day: int, + anchor_year: int, + year: int, + window_mode: str, +) -> tuple[date, date]: + """The concrete window an anchor occurrence projects onto `year`. + + This is the materialisation step: it is called ONCE per year, by + `LeaveWishService`, and the result is written into that year's own row. + Nothing re-derives it at read time — `LeaveWish.window()` just reads the + dates back off the row. + + `window_mode == 'fixed'` wishes describe a week-based habit, not a literal + month/day: someone who is off Mon-Sun this year means Mon-Sun again next + year, even though the naive month/day anchor isn't a Monday every year + (the reported bug: 2026-10-05..10-11, Mon-Sun, naively recurred to + 2027-10-05..10-11, which is Tue-Mon). So for any `year` other than + `anchor_year`, the naive `date(year, start_month, start_day)` is snapped + to the nearest date carrying the anchor's *start weekday* (ties resolve to + the earlier date — see `_shift_to_weekday`), and the end date is + re-derived by holding the anchor window's day-*span* fixed rather than + re-anchoring end_month/end_day. `anchor_year` itself is unshifted. + + `window_mode == 'flexible'` wishes (§10.2) describe a BOUNDING RANGE the + allocator searches inside, not a placement — they keep the plain month/day + anchor with no weekday shift, every year. Each year's row therefore + carries that year's bounding range, and the per-year placement search + inside it still happens at plan time (`_Ledger.resolve_flexible_window`). + """ + wraps = window_wraps_month_day(start_month, start_day, end_month, end_day) + naive_start = clamped_date(year, start_month, start_day) + naive_end = clamped_date(year + 1 if wraps else year, end_month, end_day) + + if window_mode != "fixed" or year == anchor_year: + return naive_start, naive_end + + anchor_start = clamped_date(anchor_year, start_month, start_day) + anchor_end = clamped_date(anchor_year + 1 if wraps else anchor_year, end_month, end_day) + span = anchor_end - anchor_start + + start = _shift_to_weekday(naive_start, anchor_start.weekday()) + return start, start + span + + @dataclass class LeaveWish: - """A family's recurring intent to be home for N weeks inside a yearly window.""" + """One year's intent to be home for N weeks inside a window.""" id: UUID workspace_id: UUID child_id: UUID | None # None = family-wide; set = tied to that child's pools name: str # "Sommar", "Jullov" — persisted, possibly-translated display text - # Recurring anchor stored as month/day ints (SQLite-friendly, no custom type). + # The window, stored as month/day ints anchored at `first_year` + # (SQLite-friendly, no custom type). These are this occurrence's REAL + # dates — including the weekday snap a repeating wish got when it was + # materialised — not a pattern to be re-derived per year. window_start_month: int # 1-12 window_start_day: int # 1-31 window_end_month: int window_end_day: int target_weeks: int # 6 - recurrence: str # 'yearly' | 'once' - first_year: int # 2027 - last_year: int | None # None = until the child's 4th birthday + first_year: int # the (single) year this occurrence's window starts in priority: int # 1 = highest; drives best-effort fill order parent_mode: str # 'either' | 'both' | 'specific' @@ -60,12 +140,25 @@ class LeaveWish: # are the exact placement, as above. 'flexible': the SAME four fields instead # describe the BOUNDING RANGE the allocator searches inside for the # best-costing placement (e.g. "vår" = 03-01..05-31) — target_weeks is still - # the wanted duration inside that range. recurrence/first_year/last_year are - # unchanged, so a 'yearly' flexible wish still re-resolves its bounding range - # every anchor year through window_for_year()/applies_to_year() below, - # completely unmodified. + # the wanted duration inside that range. window_mode: str = "fixed" # 'fixed' | 'flexible' + # Groups the per-year rows a repeating wish was materialised into. `None` + # = a standalone, genuinely one-off occurrence. Every row of one series + # shares this id; the service is the only writer, and it keeps the rows + # of a series consistent (same name/parent/series bound, one row per year). + series_id: UUID | None = None + + # SERIES-LEVEL intent, mirrored identically onto every row of a series: + # the final anchor year the user asked for, or `None` = "until the child + # turns 4" (the app's premise — see `AnalyticsService.get_days_left_at_age_4`). + # Always `None` when `series_id is None`. Denormalised deliberately: the + # UI reads the repeat-until control straight off whichever occurrence was + # clicked, and re-materialising a series after an edit needs the intent, + # not just the rows that happen to exist. `LeaveWishService` is the sole + # writer and rewrites it across the series whenever it changes. + last_year: int | None = None + # §Decision A (co-leave, 2026-09-18 correction) — dubbeldagar are OPT-IN, # never automatic, even when `parent_mode == 'both'` and the child is # still inside the 15-month dubbeldagar window with quota left. Only set @@ -108,63 +201,28 @@ class LeaveWish: """Whether (end_month, end_day) sorts before (start_month, start_day). A wrapping window (e.g. Jullov 12-20 -> 01-07) ends in the year *after* - the anchor year passed to `window_for_year`. + `first_year`. """ - return (self.window_end_month, self.window_end_day) < ( + return window_wraps_month_day( self.window_start_month, self.window_start_day, + self.window_end_month, + self.window_end_day, ) - def window_for_year(self, year: int) -> tuple[date, date]: - """Concrete (start_date, end_date) for this wish's window anchored at `year`. + def window(self) -> tuple[date, date]: + """This occurrence's concrete (start_date, end_date), inclusive. - `year` is the year the window *starts* in. If the window wraps, the - end date lands in `year + 1` — this also means a wrapping window spans - two different vacation-year budgets (see PLANNING_ENGINE_SPEC.md §3.2); - callers that debit vacation must account for both years separately. + A pure read of the stored fields — no recurrence projection, no + weekday snapping. Whatever shifting this occurrence needed was applied + once, at materialisation (`project_window`), and written into the row. - `window_mode == 'fixed'` wishes describe a week-based habit, not a - literal month/day: someone who is off Mon-Sun this year means Mon-Sun - again next year, even though the naive month/day anchor isn't a - Monday every year (the reported bug: 2026-10-05..10-11, Mon-Sun, - naively recurred to 2027-10-05..10-11, which is Tue-Mon). So for any - `year` after `first_year`, the naive `date(year, start_month, - start_day)` is snapped to the nearest date carrying `first_year`'s - *start weekday* (ties resolve to the earlier date — see - `_shift_to_weekday`), and the end date is re-derived by holding the - anchor window's day-*span* fixed rather than re-anchoring - end_month/end_day. `first_year` itself is always returned unshifted. - - `window_mode == 'flexible'` wishes (§10.2) describe a BOUNDING RANGE - the allocator searches inside, not a placement — they keep the plain - month/day anchor with no weekday shift, every year. + If the window wraps, the end date lands in `first_year + 1` — which + also means it spans two vacation-year budgets (see + PLANNING_ENGINE_SPEC.md §3.2); callers that debit vacation must + account for both years separately. """ - naive_start = date(year, self.window_start_month, self.window_start_day) - naive_end_year = year + 1 if self.window_wraps() else year - naive_end = date(naive_end_year, self.window_end_month, self.window_end_day) - - if self.window_mode != "fixed" or year == self.first_year: - return naive_start, naive_end - - anchor_start = date(self.first_year, self.window_start_month, self.window_start_day) - anchor_end_year = self.first_year + 1 if self.window_wraps() else self.first_year - anchor_end = date(anchor_end_year, self.window_end_month, self.window_end_day) - span = anchor_end - anchor_start - - start = _shift_to_weekday(naive_start, anchor_start.weekday()) - end = start + span + start = clamped_date(self.first_year, self.window_start_month, self.window_start_day) + end_year = self.first_year + 1 if self.window_wraps() else self.first_year + end = clamped_date(end_year, self.window_end_month, self.window_end_day) return start, end - - def applies_to_year(self, year: int) -> bool: - """Whether this wish recurs (or occurs once) with an anchor of `year`. - - `year` is compared against `first_year`/`last_year` as an *anchor* - year (the year the window starts in), not a calendar year the window - merely overlaps. - """ - if year < self.first_year: - return False - if self.recurrence == "once": - return year == self.first_year - # recurrence == 'yearly' - return self.last_year is None or year <= self.last_year diff --git a/backend/app/models/leave_wish.py b/backend/app/models/leave_wish.py index a3ea264..8fb03d4 100644 --- a/backend/app/models/leave_wish.py +++ b/backend/app/models/leave_wish.py @@ -33,8 +33,15 @@ class LeaveWishCreate(BaseModel): # A calendar-drawn period is not a whole number of weeks. None (default) # falls back to `target_weeks * 7` — see `LeaveWish.target_span_days()`. target_days: int | None = Field(default=None, ge=1) + # An INSTRUCTION, not a stored field (see app/services/leave_wish_service.py): + # 'yearly' materialises one row per year, all sharing a `series_id`, from + # `first_year` through `last_year` (or, when that is None, the child's 4th + # birthday year). 'once' creates a single standalone occurrence. What comes + # back is always one occurrence — the `first_year` one. recurrence: str = "once" # 'yearly' | 'once' first_year: int = Field(ge=2000, le=2200) + # Series-level end-year intent; None = "until the child turns 4". Ignored + # (normalised to None) when `recurrence == 'once'`. last_year: int | None = Field(default=None, ge=2000, le=2200) priority: int = Field(default=1, ge=1) @@ -46,6 +53,14 @@ class LeaveWishCreate(BaseModel): # bounding range the allocator searches within for the best-costing window. window_mode: str = "fixed" # 'fixed' | 'flexible' + # Re-attach this one occurrence to an EXISTING series (the series must + # already have at least one row in the same workspace). Only valid with + # `recurrence == 'once'` — a 'yearly' create mints its own series id. This + # exists so the calendar's undo can put back a single year it removed as + # the same year of the same repeating wish, rather than as a detached + # one-off that a later "every year" edit would miss. + series_id: UUID | None = None + # §Decision A (co-leave) — opt-in dubbeldagar; see app/entities/leave_wish.py. # Only meaningful when parent_mode == 'both'. wants_dubbeldag: bool = False @@ -72,8 +87,15 @@ class LeaveWishUpdate(BaseModel): # means "leave unchanged" — same convention as the other nullable fields # on this model (see `_clear_*` handling in `LeaveWishService.update`). target_days: int | None = Field(default=None, ge=1) + # Same instruction as on `LeaveWishCreate`, applied to an existing row: + # 'yearly' on a standalone occurrence materialises a series around it; + # 'once' on a series occurrence keeps this row and deletes its siblings. + # Absent means "leave the row's repeat-ness alone". recurrence: str | None = None first_year: int | None = Field(None, ge=2000, le=2200) + # Series-level; changing it on a row that is already part of a series + # requires `?scope=series` (the service rejects it otherwise), since it + # extends or shortens every year at once. last_year: int | None = Field(None, ge=2000, le=2200) priority: int | None = Field(None, ge=1) @@ -102,8 +124,10 @@ class LeaveWishResponse(BaseModel): target_weeks: int target_days: int | None - recurrence: str first_year: int + # Series-level intent, mirrored on every row of a series; None on a + # standalone occurrence (and on a series that runs "until the child + # turns 4"). See app/entities/leave_wish.py. last_year: int | None priority: int @@ -112,6 +136,11 @@ class LeaveWishResponse(BaseModel): window_mode: str + # Non-null when this occurrence is one year of a repeating wish; every + # year of that wish shares the value. This replaces the old `recurrence` + # field: "does this repeat?" is now "does it belong to a series?". + series_id: UUID | None + wants_dubbeldag: bool created_at: datetime | None @@ -131,13 +160,13 @@ class LeaveWishResponse(BaseModel): window_end_day=entity.window_end_day, target_weeks=entity.target_weeks, target_days=entity.target_days, - recurrence=entity.recurrence, first_year=entity.first_year, last_year=entity.last_year, priority=entity.priority, parent_mode=entity.parent_mode, parent_personal_data_uuid=entity.parent_personal_data_uuid, window_mode=entity.window_mode, + series_id=entity.series_id, wants_dubbeldag=entity.wants_dubbeldag, created_at=entity.created_at, updated_at=entity.updated_at, diff --git a/backend/app/repositories/leave_wish_repository.py b/backend/app/repositories/leave_wish_repository.py index 7719cdc..e539866 100644 --- a/backend/app/repositories/leave_wish_repository.py +++ b/backend/app/repositories/leave_wish_repository.py @@ -15,3 +15,12 @@ class LeaveWishRepository(Repository[LeaveWish]): @abstractmethod async def get_by_workspace(self, workspace_id: UUID) -> list[LeaveWish]: """Return all leave wishes for a workspace.""" + + @abstractmethod + async def get_by_series(self, series_id: UUID) -> list[LeaveWish]: + """Return every occurrence row sharing `series_id`, oldest year first. + + A repeating wish is materialised into one row per year (see + `app.entities.leave_wish`); this is how the service reaches the rest + of a series from any one of them. + """ diff --git a/backend/app/repositories_impl/sqlite/leave_wish_repository.py b/backend/app/repositories_impl/sqlite/leave_wish_repository.py index d4a82e1..751df8b 100644 --- a/backend/app/repositories_impl/sqlite/leave_wish_repository.py +++ b/backend/app/repositories_impl/sqlite/leave_wish_repository.py @@ -1,7 +1,10 @@ """SQLite implementation of LeaveWishRepository. -Handles the leave_wish table. The recurring window is stored as month/day -integers (not dates) so it can repeat yearly (see app/entities/leave_wish.py). +Handles the leave_wish table. One row is ONE year's occurrence: a repeating +wish is materialised by `LeaveWishService` into one row per year, grouped by +`series_id` (see app/entities/leave_wish.py). The window is stored as month/day +integers anchored at `first_year` rather than as two dates -- the existing +column layout, which keeps the wrap rule expressible without a custom type. """ from datetime import datetime @@ -34,7 +37,6 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): window_end_day=row["window_end_day"], target_weeks=row["target_weeks"], target_days=row["target_days"], - recurrence=row["recurrence"], first_year=row["first_year"], last_year=row["last_year"], priority=row["priority"], @@ -45,6 +47,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): else None ), window_mode=row["window_mode"], + series_id=UUID(row["series_id"]) if row["series_id"] is not None else None, wants_dubbeldag=bool(row["wants_dubbeldag"]), created_at=datetime.fromisoformat(row["created_at"]), updated_at=datetime.fromisoformat(row["updated_at"]), @@ -59,9 +62,9 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): 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, recurrence, first_year, last_year, + target_weeks, target_days, first_year, last_year, priority, parent_mode, parent_personal_data_uuid, - window_mode, wants_dubbeldag, created_at, updated_at) + window_mode, series_id, wants_dubbeldag, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( @@ -76,7 +79,6 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): item.window_end_day, item.target_weeks, item.target_days, - item.recurrence, item.first_year, item.last_year, item.priority, @@ -87,6 +89,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): else None ), item.window_mode, + str(item.series_id) if item.series_id is not None else None, int(item.wants_dubbeldag), item.created_at.isoformat(), item.updated_at.isoformat(), @@ -109,6 +112,14 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): rows = await cursor.fetchall() return [self._row_to_entity(row) for row in rows] + async def get_by_series(self, series_id: UUID) -> list[LeaveWish]: + cursor = await self._conn.execute( + "SELECT * FROM leave_wish WHERE series_id = ? ORDER BY first_year ASC", + (str(series_id),), + ) + rows = await cursor.fetchall() + return [self._row_to_entity(row) for row in rows] + async def update(self, id: UUID, item: LeaveWish) -> LeaveWish: item.updated_at = datetime.now() cursor = await self._conn.execute( @@ -117,9 +128,9 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): SET 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 = ?, + target_weeks = ?, target_days = ?, first_year = ?, last_year = ?, priority = ?, parent_mode = ?, parent_personal_data_uuid = ?, - window_mode = ?, wants_dubbeldag = ?, updated_at = ? + window_mode = ?, series_id = ?, wants_dubbeldag = ?, updated_at = ? WHERE id = ? """, ( @@ -132,7 +143,6 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): item.window_end_day, item.target_weeks, item.target_days, - item.recurrence, item.first_year, item.last_year, item.priority, @@ -143,6 +153,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): else None ), item.window_mode, + str(item.series_id) if item.series_id is not None else None, int(item.wants_dubbeldag), item.updated_at.isoformat(), str(id), diff --git a/backend/app/routers/leave_wishes.py b/backend/app/routers/leave_wishes.py index 6ebdf86..1179722 100644 --- a/backend/app/routers/leave_wishes.py +++ b/backend/app/routers/leave_wishes.py @@ -1,13 +1,20 @@ """HTTP routes for leave wishes. Routers stay thin: validate via Pydantic, call the service, map domain errors -to HTTP. Business logic (recurrence/parent_mode validation, month-day range -checks) lives in LeaveWishService. See PLANNING_ENGINE_SPEC.md §4. +to HTTP. Business logic (recurrence materialisation, parent_mode validation, +month-day range checks) lives in LeaveWishService. See PLANNING_ENGINE_SPEC.md §4. + +A repeating wish is stored as one row per year (see app/entities/leave_wish.py), +so PUT/DELETE address a single year by default. `?scope=series` widens them to +every year of the series the addressed row belongs to — the "this year" vs +"every year" choice the calendar's period popover offers. On a standalone +occurrence the two scopes are the same thing. """ +from typing import Annotated, Literal from uuid import UUID -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, HTTPException, Query, status from app.dependencies import LeaveWishServiceDep, WorkspaceIdDep, WorkspaceScopeDep from app.models.leave_wish import ( @@ -17,6 +24,11 @@ from app.models.leave_wish import ( ) from app.utils.errors import NotFoundError, ValidationError +ScopeQuery = Annotated[ + Literal["occurrence", "series"], + Query(description="'occurrence' = this year only; 'series' = every year of the wish"), +] + router = APIRouter(prefix="/api/leave-wishes", tags=["leave-wishes"]) @@ -48,6 +60,7 @@ async def create_leave_wish( parent_mode=payload.parent_mode, parent_personal_data_uuid=payload.parent_personal_data_uuid, window_mode=payload.window_mode, + series_id=payload.series_id, wants_dubbeldag=payload.wants_dubbeldag, ) except ValidationError as exc: @@ -70,17 +83,19 @@ async def update_leave_wish( leave_wish_id: UUID, payload: LeaveWishUpdate, service: LeaveWishServiceDep, - scope: WorkspaceScopeDep, + workspace_scope: WorkspaceScopeDep, + scope: ScopeQuery = "occurrence", ) -> LeaveWishResponse: if payload.child_id is not None: - await scope.check_child(payload.child_id) + await workspace_scope.check_child(payload.child_id) if payload.parent_personal_data_uuid is not None: - await scope.check_member(payload.parent_personal_data_uuid) + await workspace_scope.check_member(payload.parent_personal_data_uuid) fields_set = payload.model_fields_set try: entity = await service.update( leave_wish_id=leave_wish_id, - workspace_id=scope.workspace_id, + workspace_id=workspace_scope.workspace_id, + scope=scope, child_id=payload.child_id, name=payload.name, name_message_key=payload.name_message_key, @@ -118,9 +133,12 @@ async def update_leave_wish( @router.delete("/{leave_wish_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_leave_wish( - leave_wish_id: UUID, service: LeaveWishServiceDep, scope: WorkspaceScopeDep + leave_wish_id: UUID, + service: LeaveWishServiceDep, + workspace_scope: WorkspaceScopeDep, + scope: ScopeQuery = "occurrence", ) -> None: try: - await service.delete(leave_wish_id, workspace_id=scope.workspace_id) + await service.delete(leave_wish_id, workspace_id=workspace_scope.workspace_id, scope=scope) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index efd6578..5c4c714 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -154,8 +154,9 @@ _FRONT_LOAD_CHILD_AGE_YEARS = 2 # side of a fixed window's placed dates for a cheaper placement. Sized to # reliably catch a moving public holiday (Easter): the cached calendar # (`swedish_calendar.json`, 2026-2055) shows a max year-to-year Easter Sunday -# drift of ~20 days, and `LeaveWish.window_for_year`'s weekday-preserving -# snap (fixed-mode recurrence) can shift a naive same-month/day anchor by up +# drift of ~20 days, and the weekday-preserving snap applied when a fixed +# series is materialised (`project_window`) can shift a naive same-month/day +# anchor by up # to another 3 days -- 28 (4 weeks) covers that worst case with a small # margin. Kept a module constant, not unbounded, so `propose()` stays cheap: # each remedy call is O(bound * window_length) `is_free_day` lookups, each @@ -506,54 +507,36 @@ class _WishWindow: end: date -def _candidate_years( - wish: LeaveWish, alloc_input: AllocationInput, children_by_id: dict[UUID, ChildFacts] -) -> list[int]: - """Anchor years a wish (fixed or flexible) might occur in, before - - `applies_to_year`/horizon filtering. Shared by `_expand_all_windows` and - `_expand_flexible_wish_years` (§10.2) — identical for both, since - `window_mode` only changes how a year's `window_for_year()` result is - *used* (exact placement vs. bounding range), never which years are - considered. - """ - if wish.recurrence == "once": - return [wish.first_year] - if wish.last_year is not None: - end_year = wish.last_year - elif wish.child_id is not None and wish.child_id in children_by_id: - end_year = children_by_id[wish.child_id].fourth_birthday.year - else: - end_year = alloc_input.horizon_end.year - return list(range(wish.first_year, end_year + 1)) - - def _expand_all_windows(alloc_input: AllocationInput) -> list[_WishWindow]: - """§3.2 — expand every `window_mode == 'fixed'` wish into concrete windows. + """§3.2 — every `window_mode == 'fixed'` wish's concrete, horizon-clipped window. - Flexible wishes (§10.2) are expanded separately by - `_expand_flexible_wish_years`, since their `window_for_year()` result is a - *bounding range* to search inside, not the final placement. + One row is one year (see `app.entities.leave_wish`): a repeating wish was + materialised into per-year rows by `LeaveWishService`, each already + carrying its own dates — including the fixed-mode weekday snap, which is + applied once at materialisation rather than re-derived here. So this is + now a filter, not an expansion. + + Flexible wishes (§10.2) are handled separately by + `_expand_flexible_wish_years`, since their window is a *bounding range* to + search inside, not the final placement. """ - children_by_id = {c.id: c for c in alloc_input.children} windows: list[_WishWindow] = [] for wish in alloc_input.wishes: if wish.window_mode != "fixed": continue - for year in _candidate_years(wish, alloc_input, children_by_id): - if not wish.applies_to_year(year): - continue - start, end = wish.window_for_year(year) - if end < alloc_input.today or start > alloc_input.horizon_end: - continue - clipped_start = max(start, alloc_input.today) - clipped_end = min(end, alloc_input.horizon_end) - if clipped_start > clipped_end: - continue - windows.append( - _WishWindow(wish=wish, anchor_year=year, start=clipped_start, end=clipped_end) + start, end = wish.window() + if end < alloc_input.today or start > alloc_input.horizon_end: + continue + clipped_start = max(start, alloc_input.today) + clipped_end = min(end, alloc_input.horizon_end) + if clipped_start > clipped_end: + continue + windows.append( + _WishWindow( + wish=wish, anchor_year=wish.first_year, start=clipped_start, end=clipped_end ) + ) return windows @@ -576,31 +559,33 @@ class _FlexibleWishYear: def _expand_flexible_wish_years(alloc_input: AllocationInput) -> list[_FlexibleWishYear]: """§10.2 — the flexible-wish counterpart to `_expand_all_windows`. - Reuses `applies_to_year`, the horizon clip and the year-wrap rule - unchanged; the only difference is that `window_for_year()`'s result is - treated as a bound to search inside (§10.2 step 1), not the final window. + Same horizon clip; the only difference is that the row's window is treated + as a bound to search inside (§10.2 step 1), not the final placement. Each + year of a repeating flexible wish is its own row carrying that year's + bounding range, and the per-year placement search inside it still runs at + plan time (`_Ledger.resolve_flexible_window`) — materialising the rows + changed where the range comes from, not that the search happens. """ - children_by_id = {c.id: c for c in alloc_input.children} wish_years: list[_FlexibleWishYear] = [] for wish in alloc_input.wishes: if wish.window_mode != "flexible": continue - for year in _candidate_years(wish, alloc_input, children_by_id): - if not wish.applies_to_year(year): - continue - bound_start, bound_end = wish.window_for_year(year) - if bound_end < alloc_input.today or bound_start > alloc_input.horizon_end: - continue - clipped_start = max(bound_start, alloc_input.today) - clipped_end = min(bound_end, alloc_input.horizon_end) - if clipped_start > clipped_end: - continue - wish_years.append( - _FlexibleWishYear( - wish=wish, anchor_year=year, bound_start=clipped_start, bound_end=clipped_end - ) + bound_start, bound_end = wish.window() + if bound_end < alloc_input.today or bound_start > alloc_input.horizon_end: + continue + clipped_start = max(bound_start, alloc_input.today) + clipped_end = min(bound_end, alloc_input.horizon_end) + if clipped_start > clipped_end: + continue + wish_years.append( + _FlexibleWishYear( + wish=wish, + anchor_year=wish.first_year, + bound_start=clipped_start, + bound_end=clipped_end, ) + ) return wish_years @@ -1658,7 +1643,7 @@ class _Ledger: half_weeks = other.target_weeks // 2 if half_weeks < 1: return [] - other_start, other_end = other.window_for_year(other.first_year) + other_start, other_end = other.window() other_dates = _dates_in(other_start, other_end) spendable = sum( 1 @@ -2467,21 +2452,15 @@ def _to_child_facts(c: Child) -> ChildFacts: def _compute_horizon_end(today: date, children: list[ChildFacts], wishes: list[LeaveWish]) -> date: - """§9.1: max(last wish year-end, latest fourth_birthday), capped at age-12.""" - candidates: list[date] = [c.fourth_birthday for c in children] - children_by_id = {c.id: c for c in children} + """§9.1: max(last wish window end, latest fourth_birthday), capped at age-12. - for wish in wishes: - if wish.recurrence == "once": - end_year = wish.first_year - elif wish.last_year is not None: - end_year = wish.last_year - elif wish.child_id is not None and wish.child_id in children_by_id: - end_year = children_by_id[wish.child_id].fourth_birthday.year - else: - end_year = today.year + 4 - _, end = wish.window_for_year(end_year) - candidates.append(end) + Every wish row is one concrete year now, so the horizon is simply the + latest window end across them — no recurrence projection, and no + "open-ended until the child turns 4" case to resolve here: that was + resolved once, when `LeaveWishService` materialised the series. + """ + candidates: list[date] = [c.fourth_birthday for c in children] + candidates.extend(wish.window()[1] for wish in wishes) if not candidates: return today + relativedelta(years=4) diff --git a/backend/app/services/leave_wish_service.py b/backend/app/services/leave_wish_service.py index b3104f8..22ec17e 100644 --- a/backend/app/services/leave_wish_service.py +++ b/backend/app/services/leave_wish_service.py @@ -1,34 +1,80 @@ -"""Business logic for leave wishes (family recurring home-together intent). +"""Business logic for leave wishes (family home-together intent). See PLANNING_ENGINE_SPEC.md §2.1. This service owns the validation the -allocator (Phase B) depends on: valid recurrence/parent_mode, sane month/day -ranges, and the parent_mode <-> parent_personal_data_uuid pairing rule. +allocator depends on (valid parent_mode, sane month/day ranges, the +parent_mode <-> parent_personal_data_uuid pairing rule) **and** the +materialisation of a repeating wish into one row per year. + +Materialisation happens HERE, on create/update — not lazily at plan time. +That is the whole point of the `series_id` shape: the rows have to be real +before the user can click one year's band and edit or remove just that year. +A lazy expansion would leave nothing for the calendar to list, nothing for +`PUT /api/leave-wishes/{id}` to address, and nowhere to store the per-year +offset the planner's "shift this window onto the red days" advice produces +(Easter moves, so that advice is genuinely per year). + +`recurrence` is therefore no longer a stored field. It survives only as an +*instruction* on the create/update payloads: 'yearly' means "materialise a +series from this occurrence", 'once' means "this stands alone". What comes +back is always a concrete occurrence; `series_id` (non-NULL) is what tells a +caller the occurrence belongs to a repeating series. """ from datetime import date, datetime from uuid import UUID, uuid4 -from app.entities.leave_wish import LeaveWish +from app.entities.leave_wish import LeaveWish, project_window +from app.repositories.child_repository import ChildRepository from app.repositories.leave_wish_repository import LeaveWishRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.utils.errors import NotFoundError, ValidationError +from app.utils.holidays import cached_year_bounds _VALID_RECURRENCE = {"yearly", "once"} _VALID_PARENT_MODE = {"either", "both", "specific"} _VALID_WINDOW_MODE = {"fixed", "flexible"} +# An edit/delete either touches the clicked year only, or every year of the +# series it belongs to. On a standalone wish the two coincide. +SCOPE_OCCURRENCE = "occurrence" +SCOPE_SERIES = "series" +_VALID_SCOPE = {SCOPE_OCCURRENCE, SCOPE_SERIES} + # A leap year, so day=29 for month=2 (Feb 29) is accepted when validating ranges. _LEAP_REFERENCE_YEAR = 2028 +# How far ahead a series is materialised when nothing else bounds it. +# +# The natural bound is the child's 4th birthday year — the app's whole premise +# (see `AnalyticsService.get_days_left_at_age_4`), and exactly what the +# allocator's own year expansion used before this change. A wish not tied to a +# child has no birthday to lean on, so it falls back to the allocator's +# childless horizon rule (`_compute_horizon_end`: four years out), counted from +# `first_year` rather than from the clock so the stored rows are deterministic. +# +# Everything is then clamped to `cached_year_bounds()` — planning past +# `swedish_calendar.json` raises `UncachedYearError`, so materialising rows the +# planner could never cost would only produce 422s. `_MAX_SERIES_LENGTH_YEARS` +# is a belt-and-braces row-count bound for the (impossible today) case of a +# missing calendar cache. +_CHILDLESS_YEARS_AHEAD = 4 +_MAX_SERIES_LENGTH_YEARS = 30 + class LeaveWishService: def __init__( self, repo: LeaveWishRepository, planned_leave_repo: PlannedLeaveRepository, + child_repo: ChildRepository | None = None, ) -> None: self._repo = repo self._planned_leave_repo = planned_leave_repo + # Optional so unit tests that only exercise single-occurrence paths can + # keep constructing the service with two repos. Without it, an + # open-ended ("until the child turns 4") series falls back to the + # childless horizon instead of the child's actual 4th birthday. + self._child_repo = child_repo async def create( self, @@ -49,8 +95,25 @@ class LeaveWishService: parent_mode: str = "either", parent_personal_data_uuid: UUID | None = None, window_mode: str = "fixed", + series_id: UUID | None = None, wants_dubbeldag: bool = False, ) -> LeaveWish: + """Create one occurrence, or a whole materialised series. + + `recurrence='yearly'` mints a `series_id` and writes one row per year + from `first_year` through the resolved series end (see + `_resolve_series_end_year`). The returned entity is always the + `first_year` occurrence — the one whose window is exactly what the + caller asked for, and the one a caller watching "the wish it just + created" means. + + `series_id` (only with `recurrence='once'`) re-attaches a single + occurrence to an EXISTING series in the same workspace — the undo of + a "remove just this year", which must put the year back as part of + the repeating wish rather than as a detached one-off. The series' + `last_year` intent is copied from its surviving rows so the mirror + stays consistent. + """ _validate( window_start_month=window_start_month, window_start_day=window_start_day, @@ -66,8 +129,20 @@ class LeaveWishService: window_mode=window_mode, wants_dubbeldag=wants_dubbeldag, ) + repeats = recurrence == "yearly" + if series_id is not None and repeats: + raise ValidationError( + "series_id may not be combined with recurrence='yearly' " + "(a repeating wish mints its own series)" + ) + if series_id is not None: + existing = await self._repo.get_by_series(series_id) + if not existing or existing[0].workspace_id != workspace_id: + raise ValidationError(f"series_id {series_id} is not a series in this workspace") + last_year = existing[0].last_year + repeats = True # it IS part of a series; it just doesn't create one now = datetime.now() - entity = LeaveWish( + anchor = LeaveWish( id=uuid4(), workspace_id=workspace_id, child_id=child_id, @@ -79,18 +154,23 @@ class LeaveWishService: window_end_day=window_end_day, target_weeks=target_weeks, target_days=target_days, - recurrence=recurrence, first_year=first_year, - last_year=last_year, + last_year=last_year if repeats else None, priority=priority, parent_mode=parent_mode, parent_personal_data_uuid=parent_personal_data_uuid, window_mode=window_mode, + series_id=series_id if series_id is not None else (uuid4() if repeats else None), wants_dubbeldag=wants_dubbeldag, created_at=now, updated_at=now, ) - return await self._repo.create(entity) + created = await self._repo.create(anchor) + if repeats and series_id is None: + end_year = await self._resolve_series_end_year(child_id, first_year, last_year) + for year in range(first_year + 1, end_year + 1): + await self._repo.create(_project_occurrence(created, year)) + return created async def get(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> LeaveWish: """`workspace_id`, when given, must match the entity's own — callers @@ -128,6 +208,7 @@ class LeaveWishService: parent_personal_data_uuid: UUID | None = None, window_mode: str | None = None, wants_dubbeldag: bool | None = None, + scope: str = SCOPE_OCCURRENCE, _clear_child_id: bool = False, _clear_last_year: bool = False, _clear_parent_personal_data_uuid: bool = False, @@ -136,6 +217,19 @@ class LeaveWishService: ) -> LeaveWish: """Partial update — only non-None fields are applied. + `scope` decides how far the edit reaches: + + * `'occurrence'` (default) — this year's row only. Siblings are not + touched, which is the point of materialising in the first place. + * `'series'` — every row sharing this row's `series_id`. The edited + row becomes the series' new anchor and every other year is + re-projected from it (`project_window`), so a fixed wish keeps its + weekday alignment across the series exactly as before. + + `recurrence` is an instruction, not a field: `'yearly'` on a + standalone wish materialises a series around it; `'once'` on a series + occurrence keeps this row and deletes its siblings. + `_clear_*` flags exist because a nullable field's "no change" and "set to None" both look like `None` on the wire; the router passes them based on whether the field was present in the request payload. @@ -149,9 +243,24 @@ class LeaveWishService: `_clear_target_days` resets a day-granular wish back to being sized by `target_weeks` alone. """ + if scope not in _VALID_SCOPE: + raise ValidationError(f"scope must be one of {_VALID_SCOPE}") + if recurrence is not None and recurrence not in _VALID_RECURRENCE: + raise ValidationError(f"recurrence must be one of {_VALID_RECURRENCE}") + entity = await self.get(leave_wish_id, workspace_id) + was_series = entity.series_id is not None original_name = entity.name + # What the caller asked the row's repeat-ness to become. Absent + # `recurrence` means "leave it as it is". + will_repeat = was_series if recurrence is None else recurrence == "yearly" + touches_last_year = _clear_last_year or last_year is not None + if touches_last_year and will_repeat and scope != SCOPE_SERIES and was_series: + raise ValidationError( + "last_year is a property of the whole series; pass scope='series' to change it" + ) + if _clear_child_id: entity.child_id = None elif child_id is not None: @@ -178,14 +287,8 @@ class LeaveWishService: entity.target_days = None elif target_days is not None: entity.target_days = target_days - if recurrence is not None: - entity.recurrence = recurrence if first_year is not None: entity.first_year = first_year - if _clear_last_year: - entity.last_year = None - elif last_year is not None: - entity.last_year = last_year if priority is not None: entity.priority = priority if parent_mode is not None: @@ -199,6 +302,16 @@ class LeaveWishService: if wants_dubbeldag is not None: entity.wants_dubbeldag = wants_dubbeldag + # `last_year` is the series' intent, mirrored on every row. A + # standalone occurrence never carries one. + if will_repeat: + if _clear_last_year: + entity.last_year = None + elif last_year is not None: + entity.last_year = last_year + else: + entity.last_year = None + _validate( window_start_month=entity.window_start_month, window_start_day=entity.window_start_day, @@ -206,7 +319,7 @@ class LeaveWishService: window_end_day=entity.window_end_day, target_weeks=entity.target_weeks, target_days=entity.target_days, - recurrence=entity.recurrence, + recurrence="yearly" if will_repeat else "once", first_year=entity.first_year, last_year=entity.last_year, parent_mode=entity.parent_mode, @@ -214,10 +327,42 @@ class LeaveWishService: window_mode=entity.window_mode, wants_dubbeldag=entity.wants_dubbeldag, ) - return await self._repo.update(leave_wish_id, entity) - async def delete(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> None: - """Delete a wish and every `planned_leave_day` row it produced. + siblings = ( + [s for s in await self._repo.get_by_series(entity.series_id) if s.id != entity.id] + if entity.series_id is not None + else [] + ) + + if was_series and not will_repeat: + # "Stop repeating": keep the clicked occurrence, drop the rest. + entity.series_id = None + await self._delete_rows(siblings) + return await self._repo.update(entity.id, entity) + + if not will_repeat: + return await self._repo.update(entity.id, entity) + + if not was_series: + # "Make this repeat": this occurrence becomes the series anchor. + entity.series_id = uuid4() + + anchor = await self._repo.update(entity.id, entity) + if was_series and scope != SCOPE_SERIES: + # A single year of an existing series changed; siblings keep + # whatever the user (or the planner) had already given them. + return anchor + await self._materialise_series(anchor, siblings) + return anchor + + async def delete( + self, + leave_wish_id: UUID, + workspace_id: UUID | None = None, + scope: str = SCOPE_OCCURRENCE, + ) -> None: + """Delete a wish occurrence (or a whole series) and every + `planned_leave_day` row it produced. Under the calendar-direct-manipulation UX a period *is* its wish: the band the user drew, the days the allocator filled it with, and any @@ -237,10 +382,122 @@ class LeaveWishService: (`Calendar.handleDeletePeriod`'s no-`wishId` branch), which already deletes every day in the band regardless of `is_pinned` with no special-casing. + + `scope='series'` removes every year of the repeating wish this + occurrence belongs to; `'occurrence'` removes just this year's row, + which is now a plain delete rather than the range-narrowing (and + split-the-wish-in-two) contortion the single-row shape forced on the + frontend. """ - await self.get(leave_wish_id, workspace_id) # raises NotFoundError if missing/foreign - await self._planned_leave_repo.delete_by_wish(leave_wish_id) - await self._repo.delete(leave_wish_id) + if scope not in _VALID_SCOPE: + raise ValidationError(f"scope must be one of {_VALID_SCOPE}") + entity = await self.get(leave_wish_id, workspace_id) + targets = [entity] + if scope == SCOPE_SERIES and entity.series_id is not None: + targets = await self._repo.get_by_series(entity.series_id) + await self._delete_rows(targets) + + # -- internals --------------------------------------------------------- + + async def _delete_rows(self, rows: list[LeaveWish]) -> None: + for row in rows: + await self._planned_leave_repo.delete_by_wish(row.id) + await self._repo.delete(row.id) + + async def _materialise_series(self, anchor: LeaveWish, siblings: list[LeaveWish]) -> None: + """Make the rows of `anchor`'s series agree with `anchor`. + + Every year in the series is re-projected from `anchor` — that is what + "apply this to every year" means, and it is the same projection the + old single-row shape ran at read time, just written down. Years past + the (possibly newly shortened) end are deleted; years newly inside it + are created. The anchor row itself is never touched here: it already + holds exactly the dates the user typed. + """ + assert anchor.series_id is not None # only called on a series row + end_year = await self._resolve_series_end_year( + anchor.child_id, anchor.first_year, anchor.last_year + ) + start_year = min([anchor.first_year, *(s.first_year for s in siblings)]) + wanted = set(range(start_year, end_year + 1)) + + by_year: dict[int, LeaveWish] = {} + stale: list[LeaveWish] = [] + for sibling in siblings: + if sibling.first_year in wanted and sibling.first_year not in by_year: + by_year[sibling.first_year] = sibling + else: + # Out of range, or a duplicate year (only reachable if a + # `first_year` edit collided with an existing sibling). + stale.append(sibling) + await self._delete_rows(stale) + + for year in sorted(wanted - {anchor.first_year}): + projected = _project_occurrence(anchor, year) + existing = by_year.get(year) + if existing is None: + await self._repo.create(projected) + continue + projected.id = existing.id + projected.created_at = existing.created_at + await self._repo.update(existing.id, projected) + + async def _resolve_series_end_year( + self, child_id: UUID | None, first_year: int, last_year: int | None + ) -> int: + """Last anchor year a series is materialised for. See the module's + `_CHILDLESS_YEARS_AHEAD` comment for why these bounds and not others. + """ + if last_year is not None: + end_year = last_year + else: + end_year = first_year + _CHILDLESS_YEARS_AHEAD + if child_id is not None and self._child_repo is not None: + child = await self._child_repo.get_by_id(child_id) + if child is not None: + end_year = child.birth_date.year + 4 + bounds = cached_year_bounds() + if bounds is not None: + end_year = min(end_year, bounds[1]) + end_year = min(end_year, first_year + _MAX_SERIES_LENGTH_YEARS) + return max(end_year, first_year) + + +def _project_occurrence(anchor: LeaveWish, year: int) -> LeaveWish: + """A brand-new row for `year`, carrying `anchor`'s projected window.""" + start, end = project_window( + start_month=anchor.window_start_month, + start_day=anchor.window_start_day, + end_month=anchor.window_end_month, + end_day=anchor.window_end_day, + anchor_year=anchor.first_year, + year=year, + window_mode=anchor.window_mode, + ) + now = datetime.now() + return LeaveWish( + id=uuid4(), + workspace_id=anchor.workspace_id, + child_id=anchor.child_id, + name=anchor.name, + name_message_key=anchor.name_message_key, + window_start_month=start.month, + window_start_day=start.day, + window_end_month=end.month, + window_end_day=end.day, + target_weeks=anchor.target_weeks, + target_days=anchor.target_days, + first_year=year, + last_year=anchor.last_year, + priority=anchor.priority, + parent_mode=anchor.parent_mode, + parent_personal_data_uuid=anchor.parent_personal_data_uuid, + window_mode=anchor.window_mode, + series_id=anchor.series_id, + wants_dubbeldag=anchor.wants_dubbeldag, + created_at=now, + updated_at=now, + ) def _validate( @@ -276,6 +533,10 @@ def _validate( if target_days is not None and target_days < 1: raise ValidationError("target_days must be >= 1") + # `last_year` is the series' end-year intent and is meaningless on a + # standalone occurrence; `create`/`update` normalise it to None there + # rather than rejecting it, so an older client that still sends one on a + # 'once' wish keeps working. if last_year is not None and last_year < first_year: raise ValidationError("last_year must be >= first_year") @@ -326,9 +587,9 @@ def _bound_length_days( ) -> int: """Span (inclusive, in days) of a window/bound anchored at `anchor_year`. - Mirrors `LeaveWish.window_wraps()`/`window_for_year()` without requiring a - full entity — used at validation time (§10.1), before an entity exists in - `create()`. `anchor_year` should be a real calendar year (not the leap + Mirrors `LeaveWish.window_wraps()`/`LeaveWish.window()` without requiring + a full entity — used at validation time (§10.1), before an entity exists + in `create()`. `anchor_year` should be a real calendar year (not the leap reference year `_validate_month_day` uses) so Feb 29 in a non-leap `first_year` naturally raises via `date()`, consistent with `_validate_month_day`'s own calendar-date check. diff --git a/backend/app/services/tips_service.py b/backend/app/services/tips_service.py index 89b1d33..46cf937 100644 --- a/backend/app/services/tips_service.py +++ b/backend/app/services/tips_service.py @@ -481,20 +481,18 @@ async def _rule_wrap_red_days_with_vacation(ctx: TipsContext) -> Tip | None: def _concrete_windows_near_today(wish: LeaveWish, today: date) -> list[tuple[date, date]]: - """Concretize a recurring wish into the window(s) anchored near `today`. + """This wish's window, if it is anchored near `today` — else nothing. - Only a small neighbourhood of anchor years is considered (last year, - this year, next year) — enough to catch a window that started last - December and wraps into January (see LeaveWish.window_for_year) without - materialising the wish's entire multi-year horizon. Full expansion across - the whole horizon is the allocator's job (PLANNING_ENGINE_SPEC.md §3.2), - not the tips engine's. + One wish row is one year (see `app.entities.leave_wish`), so there is + nothing to concretize any more; the tips engine just ignores occurrences + far from today. The neighbourhood is last year / this year / next year — + "last year" is what catches a window that started last December and wraps + into January. Reasoning over the whole horizon is the allocator's job + (PLANNING_ENGINE_SPEC.md §3.2), not the tips engine's. """ - windows = [] - for year in (today.year - 1, today.year, today.year + 1): - if wish.applies_to_year(year): - windows.append(wish.window_for_year(year)) - return windows + if wish.first_year not in (today.year - 1, today.year, today.year + 1): + return [] + return [wish.window()] async def _rule_long_break_parent_swap(ctx: TipsContext) -> Tip | None: diff --git a/backend/migrations/versions/004_leave_wish_series.py b/backend/migrations/versions/004_leave_wish_series.py new file mode 100644 index 0000000..4899f55 --- /dev/null +++ b/backend/migrations/versions/004_leave_wish_series.py @@ -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) diff --git a/backend/scripts/demo_plan.py b/backend/scripts/demo_plan.py index 49b5bd5..033f431 100644 --- a/backend/scripts/demo_plan.py +++ b/backend/scripts/demo_plan.py @@ -20,7 +20,7 @@ from uuid import uuid4 from dateutil.relativedelta import relativedelta -from app.entities.leave_wish import LeaveWish +from app.entities.leave_wish import LeaveWish, project_window from app.services.allocation_service import ( AllocationInput, Allocator, @@ -82,40 +82,46 @@ def _build_input() -> tuple[AllocationInput, dict]: } ) - summer_wish = LeaveWish( - id=uuid4(), - workspace_id=uuid4(), - child_id=child.id, - name="Sommar", - window_start_month=6, - window_start_day=15, - window_end_month=7, - window_end_day=26, # 6 weeks - target_weeks=6, - recurrence="yearly", - first_year=CHILD_BIRTH_DATE.year, - last_year=None, # until the child's 4th birthday - priority=1, - parent_mode="either", # lets the allocator swap between parents - parent_personal_data_uuid=None, - ) - christmas_wish = LeaveWish( - id=uuid4(), - workspace_id=summer_wish.workspace_id, - child_id=child.id, - name="Jul", - window_start_month=12, - window_start_day=20, - window_end_month=1, - window_end_day=16, # wraps into January; 28 days = 4 weeks - target_weeks=4, - recurrence="yearly", - first_year=CHILD_BIRTH_DATE.year, - last_year=None, - priority=1, - parent_mode="either", - parent_personal_data_uuid=None, - ) + # A repeating wish is one row per year now (see app/entities/leave_wish.py); + # `LeaveWishService` does this materialisation for real callers, so this + # script — which builds an `AllocationInput` by hand, with no repositories — + # spells it out with the same `project_window` projection. + def _series(name: str, window: tuple[int, int, int, int], weeks: int) -> list[LeaveWish]: + start_month, start_day, end_month, end_day = window + workspace_id = uuid4() + rows: list[LeaveWish] = [] + for year in range(CHILD_BIRTH_DATE.year, CHILD_BIRTH_DATE.year + 5): + start, end = project_window( + start_month=start_month, + start_day=start_day, + end_month=end_month, + end_day=end_day, + anchor_year=CHILD_BIRTH_DATE.year, + year=year, + window_mode="fixed", + ) + rows.append( + LeaveWish( + id=uuid4(), + workspace_id=workspace_id, + child_id=child.id, + name=name, + window_start_month=start.month, + window_start_day=start.day, + window_end_month=end.month, + window_end_day=end.day, + target_weeks=weeks, + first_year=year, + last_year=None, # until the child's 4th birthday + priority=1, + parent_mode="either", # lets the allocator swap between parents + parent_personal_data_uuid=None, + ) + ) + return rows + + # Sommar: 6 weeks. Jul: 4 weeks, wrapping into January. + wishes = _series("Sommar", (6, 15, 7, 26), 6) + _series("Jul", (12, 20, 1, 16), 4) holidays_map: dict[date, str] = {} for year in range(TODAY.year, horizon_end.year + 2): @@ -129,7 +135,7 @@ def _build_input() -> tuple[AllocationInput, dict]: parents=[parent_1, parent_2], pools=pools, vacation=vacation, - wishes=[summer_wish, christmas_wish], + wishes=wishes, existing_planned=[], holidays=holidays_map, ) diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py index 6d029bb..06fcc54 100644 --- a/backend/scripts/seed_demo.py +++ b/backend/scripts/seed_demo.py @@ -135,7 +135,7 @@ def _build_services(conn: aiosqlite.Connection) -> DemoServices: child=ChildService(children), vacation=VacationDaysService(vacation_repo), day_override=DayOverrideService(day_overrides), - leave_wish=LeaveWishService(leave_wishes, planned_leave), + leave_wish=LeaveWishService(leave_wishes, planned_leave, children), manual_fetch=ManualFetchService(parental_leave), planning=PlanningService(planned_leave, analytics, parental_leave, vacation_repo), users=users, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6be0195..4abfb75 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -273,8 +273,11 @@ async def leave_wish_repo( async def leave_wish_service( leave_wish_repo: SQLiteLeaveWishRepository, planned_leave_repo: SQLitePlannedLeaveRepository, + child_repo: SQLiteChildRepository, ) -> LeaveWishService: - return LeaveWishService(leave_wish_repo, planned_leave_repo) + # `child_repo` lets an open-ended ("until the child turns 4") series + # materialise against the child's real birth date; see LeaveWishService. + return LeaveWishService(leave_wish_repo, planned_leave_repo, child_repo) # --------------------------------------------------------------------------- diff --git a/backend/tests/integration/test_migration_004_backfill.py b/backend/tests/integration/test_migration_004_backfill.py new file mode 100644 index 0000000..9078a69 --- /dev/null +++ b/backend/tests/integration/test_migration_004_backfill.py @@ -0,0 +1,285 @@ +"""Migration 004's backfill: one `recurrence='yearly'` row -> one row per year. + +`test_schema_parity.py` proves the migration chain and `_FULL_SCHEMA` describe +the same *shape*. This proves 004 moves the *data* correctly: an existing +repeating wish must come out the other side as a real row per year, sharing a +`series_id`, each carrying the dates the pre-004 `window_for_year()` would +have produced for that year — including the fixed-mode weekday snap. Getting +that wrong would silently move every recurring family's summer. + +Driven through the real Alembic chain (upgrade to 003, insert pre-004 rows, +upgrade to head), not through `_FULL_SCHEMA`, because the pre-004 shape no +longer exists anywhere else. +""" + +from __future__ import annotations + +import sqlite3 +import uuid +from datetime import date, datetime +from pathlib import Path + +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_pre_004_fixture( + db_path: Path, + *, + child_birth_date: date | None, + wishes: list[dict[str, object]], +) -> dict[str, str]: + """Seed a workspace (+ optional child) and the given pre-004 leave_wish rows.""" + now = datetime(2026, 1, 1, 12, 0, 0).isoformat() + ws_id = str(uuid.uuid4()) + child_id = str(uuid.uuid4()) if child_birth_date is not None else None + + conn = sqlite3.connect(db_path) + try: + conn.execute( + "INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)", + (ws_id, "WS", now), + ) + if child_id is not None: + assert child_birth_date is not None + conn.execute( + "INSERT INTO child (id, workspace_id, name, birth_date, " + "preschool_start_date, preschool_planning_days_per_year, created_at) " + "VALUES (?, ?, ?, ?, NULL, 0, ?)", + (child_id, ws_id, "Barn", child_birth_date.isoformat(), now), + ) + for wish in wishes: + 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, recurrence, first_year, last_year, priority, parent_mode, " + "window_mode, created_at, updated_at) " + "VALUES (:id, :workspace_id, :child_id, :name, :wsm, :wsd, :wem, :wed, " + ":target_weeks, :recurrence, :first_year, :last_year, 1, 'either', " + ":window_mode, :now, :now)", + { + "workspace_id": ws_id, + "child_id": child_id if wish.get("tied_to_child", True) else None, + "now": now, + **{k: v for k, v in wish.items() if k != "tied_to_child"}, + }, + ) + conn.commit() + finally: + conn.close() + return {"workspace_id": ws_id, "child_id": child_id or ""} + + +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 test_yearly_wish_is_expanded_into_one_row_per_year(tmp_path: Path) -> None: + db = tmp_path / "backfill.db" + command.upgrade(_alembic_config(db), "003") + + anchor_id = str(uuid.uuid4()) + _insert_pre_004_fixture( + db, + child_birth_date=date(2026, 5, 1), + wishes=[ + { + "id": anchor_id, + "name": "Sommar", + # 2026-10-05 is a Monday; 10-11 the Sunday after. + "wsm": 10, + "wsd": 5, + "wem": 10, + "wed": 11, + "target_weeks": 1, + "recurrence": "yearly", + "first_year": 2026, + "last_year": 2029, + "window_mode": "fixed", + } + ], + ) + + command.upgrade(_alembic_config(db), "head") + + rows = _rows(db, "Sommar") + assert [r["first_year"] for r in rows] == [2026, 2027, 2028, 2029] + + # One series, and the original row survives as the first year's occurrence + # (so the `planned_leave_day.wish_id` rows pointing at it stay attached). + series_ids = {r["series_id"] for r in rows} + assert len(series_ids) == 1 + assert None not in series_ids + assert rows[0]["id"] == anchor_id + + # The weekday snap is now IN the rows: each year keeps Monday->Sunday + # rather than drifting to the naive 10-05 (a Tuesday in 2027). + stored = [(r["window_start_month"], r["window_start_day"]) for r in rows] + assert stored == [(10, 5), (10, 4), (10, 2), (10, 8)] + 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.weekday() == 6, row["first_year"] + assert (end - start).days == 6 + + # Series-level intent is mirrored onto every row. + assert {r["last_year"] for r in rows} == {2029} + # And the dropped `recurrence` column really is gone. + assert "recurrence" not in rows[0].keys() + + +def test_open_ended_yearly_wish_stops_at_the_childs_fourth_birthday_year(tmp_path: Path) -> None: + db = tmp_path / "age4.db" + command.upgrade(_alembic_config(db), "003") + + _insert_pre_004_fixture( + db, + child_birth_date=date(2026, 5, 1), # 4th birthday in 2030 + wishes=[ + { + "id": str(uuid.uuid4()), + "name": "Jul", + "wsm": 12, + "wsd": 20, + "wem": 1, + "wed": 6, # wraps into January + "target_weeks": 3, + "recurrence": "yearly", + "first_year": 2026, + "last_year": None, # "until the child turns 4" + "window_mode": "fixed", + } + ], + ) + + command.upgrade(_alembic_config(db), "head") + + rows = _rows(db, "Jul") + assert [r["first_year"] for r in rows] == [2026, 2027, 2028, 2029, 2030] + assert {r["last_year"] for r in rows} == {None} + # Still a wrapping window on every row: end sorts before start. + for row in rows: + assert (row["window_end_month"], row["window_end_day"]) < ( + row["window_start_month"], + row["window_start_day"], + ) + + +def test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year(tmp_path: Path) -> None: + db = tmp_path / "flexible.db" + command.upgrade(_alembic_config(db), "003") + + _insert_pre_004_fixture( + db, + child_birth_date=date(2026, 5, 1), + wishes=[ + { + "id": str(uuid.uuid4()), + "name": "Var", + "wsm": 3, + "wsd": 1, + "wem": 5, + "wed": 31, + "target_weeks": 1, + "recurrence": "yearly", + "first_year": 2027, + "last_year": 2029, + "window_mode": "flexible", + } + ], + ) + + command.upgrade(_alembic_config(db), "head") + + rows = _rows(db, "Var") + assert [r["first_year"] for r in rows] == [2027, 2028, 2029] + # A flexible wish's four fields are a bounding range, never a placement — + # no weekday snap, so every year's range is the identical month/day pair. + assert { + (r["window_start_month"], r["window_start_day"], r["window_end_month"], r["window_end_day"]) + for r in rows + } == {(3, 1, 5, 31)} + + +def test_once_wish_is_left_alone_with_no_series(tmp_path: Path) -> None: + db = tmp_path / "once.db" + command.upgrade(_alembic_config(db), "003") + + _insert_pre_004_fixture( + db, + child_birth_date=date(2026, 5, 1), + wishes=[ + { + "id": str(uuid.uuid4()), + "name": "Engang", + "wsm": 4, + "wsd": 1, + "wem": 4, + "wed": 7, + "target_weeks": 1, + "recurrence": "once", + "first_year": 2027, + "last_year": None, + "window_mode": "fixed", + } + ], + ) + + command.upgrade(_alembic_config(db), "head") + + rows = _rows(db, "Engang") + assert len(rows) == 1 + assert rows[0]["series_id"] is None + assert (rows[0]["window_start_month"], rows[0]["window_start_day"]) == (4, 1) + + +def test_downgrade_collapses_a_series_back_to_its_first_year(tmp_path: Path) -> None: + db = tmp_path / "roundtrip.db" + command.upgrade(_alembic_config(db), "003") + + anchor_id = str(uuid.uuid4()) + _insert_pre_004_fixture( + db, + child_birth_date=date(2026, 5, 1), + wishes=[ + { + "id": anchor_id, + "name": "Sommar", + "wsm": 6, + "wsd": 15, + "wem": 7, + "wed": 26, + "target_weeks": 6, + "recurrence": "yearly", + "first_year": 2026, + "last_year": 2029, + "window_mode": "fixed", + } + ], + ) + + command.upgrade(_alembic_config(db), "head") + assert len(_rows(db, "Sommar")) == 4 + + command.downgrade(_alembic_config(db), "003") + + rows = _rows(db, "Sommar") + assert len(rows) == 1 + assert rows[0]["id"] == anchor_id + assert rows[0]["recurrence"] == "yearly" + assert rows[0]["first_year"] == 2026 + assert (rows[0]["window_start_month"], rows[0]["window_start_day"]) == (6, 15) diff --git a/backend/tests/integration/test_user_journey.py b/backend/tests/integration/test_user_journey.py index 87b3c80..0132258 100644 --- a/backend/tests/integration/test_user_journey.py +++ b/backend/tests/integration/test_user_journey.py @@ -588,13 +588,24 @@ class TestFlexibleYearlyWishTracksEasterEachYear: last_year=years[-1], window_mode="flexible", ) - wish_id = wish["id"] + # A repeating wish is materialised into one row per year sharing a + # `series_id`; the create response is the first year's row, so the + # later years' outcomes carry their OWN row ids. + assert wish["series_id"] is not None + listed = await client.get(f"/api/leave-wishes/{ws_id}", headers=headers) + assert listed.status_code == 200, listed.text + wish_id_by_year = { + row["first_year"]: row["id"] + for row in listed.json() + if row["series_id"] == wish["series_id"] + } + assert set(wish_id_by_year) == set(years), wish_id_by_year proposal = await _propose(client, headers, ws_id) windows: dict[int, tuple[date, date]] = {} for year in years: - outcome = _outcome_for_wish(proposal, wish_id, year=year) + outcome = _outcome_for_wish(proposal, wish_id_by_year[year], year=year) assert outcome["strategy"] != "unplaced", outcome assert outcome["shortfall_days"] == 0, outcome start = date.fromisoformat(outcome["start_date"]) diff --git a/backend/tests/unit/test_authz.py b/backend/tests/unit/test_authz.py index d38615b..413f882 100644 --- a/backend/tests/unit/test_authz.py +++ b/backend/tests/unit/test_authz.py @@ -370,7 +370,6 @@ class TestForeignWorkspaceIdIs404: window_end_month=1, window_end_day=7, target_weeks=1, - recurrence="once", first_year=2030, last_year=None, priority=1, @@ -396,7 +395,6 @@ class TestForeignWorkspaceIdIs404: window_end_month=1, window_end_day=7, target_weeks=1, - recurrence="once", first_year=2030, last_year=None, priority=1, diff --git a/backend/tests/unit/test_db_unit_of_work.py b/backend/tests/unit/test_db_unit_of_work.py index efc847c..df0955f 100644 --- a/backend/tests/unit/test_db_unit_of_work.py +++ b/backend/tests/unit/test_db_unit_of_work.py @@ -173,7 +173,6 @@ class TestPlannedLeaveRepositoryBatchMethods: window_end_month=4, window_end_day=30, target_weeks=1, - recurrence="once", first_year=2026, last_year=None, priority=1, diff --git a/backend/tests/unit/test_leave_wish_repository.py b/backend/tests/unit/test_leave_wish_repository.py index ff9cfd6..c17cf97 100644 --- a/backend/tests/unit/test_leave_wish_repository.py +++ b/backend/tests/unit/test_leave_wish_repository.py @@ -5,6 +5,7 @@ Aim for >=90% coverage on new code (ARCHITECTURE.md §10). """ from datetime import date, datetime +from typing import Any from uuid import UUID, uuid4 import pytest @@ -20,6 +21,10 @@ from app.utils.errors import NotFoundError, ValidationError from tests.conftest import SeededIds +# A stable series id for tests that only need "this row belongs to a series". +_SERIES_ID = UUID("11111111-2222-3333-4444-555555555555") + + def _make(workspace_id=None, **overrides) -> LeaveWish: """Helper to create a test LeaveWish entity.""" base = dict( @@ -33,7 +38,6 @@ def _make(workspace_id=None, **overrides) -> LeaveWish: window_end_day=15, target_weeks=6, target_days=None, - recurrence="yearly", first_year=2027, last_year=None, priority=1, @@ -47,9 +51,9 @@ def _make(workspace_id=None, **overrides) -> LeaveWish: class TestLeaveWishWindowLogic: - """Direct tests of LeaveWish's recurring-window helpers. + """Direct tests of LeaveWish's window helpers. - These are the rules the allocator (Phase B) depends on most — especially + These are the rules the allocator depends on most — especially year-wrapping, which is the easiest thing in this feature to get backwards. """ @@ -70,40 +74,60 @@ class TestLeaveWishWindowLogic: wish = _make(window_start_month=6, window_start_day=1, window_end_month=6, window_end_day=1) assert wish.window_wraps() is False - def test_window_for_year_no_wrap_stays_in_same_year(self) -> None: + def test_window_no_wrap_stays_in_same_year(self) -> None: wish = _make( - window_start_month=6, window_start_day=15, window_end_month=8, window_end_day=15 + first_year=2027, + window_start_month=6, + window_start_day=15, + window_end_month=8, + window_end_day=15, ) - start, end = wish.window_for_year(2027) + start, end = wish.window() assert start == date(2027, 6, 15) assert end == date(2027, 8, 15) - def test_window_for_year_wraps_end_into_next_year(self) -> None: + def test_window_wraps_end_into_next_year(self) -> None: wish = _make( - window_start_month=12, window_start_day=20, window_end_month=1, window_end_day=7 + first_year=2027, + window_start_month=12, + window_start_day=20, + window_end_month=1, + window_end_day=7, ) - start, end = wish.window_for_year(2027) + start, end = wish.window() assert start == date(2027, 12, 20) assert end == date(2028, 1, 7) - def test_applies_to_year_before_first_year_is_false(self) -> None: - wish = _make(recurrence="yearly", first_year=2027, last_year=None) - assert wish.applies_to_year(2026) is False + def test_window_reads_the_row_back_verbatim_with_no_projection(self) -> None: + """Two rows of one series that diverged (e.g. a per-year Easter shift) + each report their OWN dates. The old shape re-derived every later year + from `first_year`, so this could not be expressed at all. + """ + series = uuid4() + a = _make(first_year=2027, series_id=series) + b = _make( + first_year=2028, + series_id=series, + window_start_month=7, + window_start_day=1, + window_end_month=8, + window_end_day=31, + ) + assert a.window() == (date(2027, 6, 15), date(2027, 8, 15)) + assert b.window() == (date(2028, 7, 1), date(2028, 8, 31)) - def test_applies_to_year_once_matches_only_first_year(self) -> None: - wish = _make(recurrence="once", first_year=2027, last_year=2027) - assert wish.applies_to_year(2027) is True - assert wish.applies_to_year(2028) is False - - def test_applies_to_year_yearly_with_no_last_year_recurs_indefinitely(self) -> None: - wish = _make(recurrence="yearly", first_year=2027, last_year=None) - assert wish.applies_to_year(2027) is True - assert wish.applies_to_year(2050) is True - - def test_applies_to_year_yearly_stops_after_last_year(self) -> None: - wish = _make(recurrence="yearly", first_year=2027, last_year=2029) - assert wish.applies_to_year(2029) is True - assert wish.applies_to_year(2030) is False + def test_window_clamps_a_feb_29_anchor_in_a_non_leap_year(self) -> None: + """A Feb-29 window in a non-leap year used to raise ValueError out of + the allocator; a whole-day planner has to answer Feb 28 instead. + """ + wish = _make( + first_year=2027, + window_start_month=2, + window_start_day=29, + window_end_month=3, + window_end_day=5, + ) + assert wish.window() == (date(2027, 2, 28), date(2027, 3, 5)) # -- target_span_days() (calendar-drawn periods, day-granular) --------- @@ -134,7 +158,7 @@ class TestSQLiteLeaveWishRepository: assert fetched.id == created.id assert fetched.name == "Sommar" assert fetched.target_weeks == 6 - assert fetched.recurrence == "yearly" + assert fetched.series_id is None assert fetched.parent_mode == "either" assert fetched.child_id is None assert fetched.parent_personal_data_uuid is None @@ -211,7 +235,7 @@ class TestSQLiteLeaveWishRepository: window_end_month=1, window_end_day=7, target_weeks=4, - recurrence="yearly", + series_id=_SERIES_ID, first_year=2027, last_year=2031, priority=2, @@ -227,7 +251,7 @@ class TestSQLiteLeaveWishRepository: assert created.window_end_month == 1 assert created.window_end_day == 7 assert created.target_weeks == 4 - assert created.recurrence == "yearly" + assert created.series_id == _SERIES_ID assert created.first_year == 2027 assert created.last_year == 2031 assert created.priority == 2 @@ -239,6 +263,7 @@ class TestSQLiteLeaveWishRepository: assert fetched is not None assert fetched.child_id == seeded_ids.child_id assert fetched.last_year == 2031 + assert fetched.series_id == _SERIES_ID assert fetched.parent_personal_data_uuid == seeded_ids.personal_data_uuid async def test_create_and_update_round_trip_target_days( @@ -480,7 +505,7 @@ class TestLeaveWishService: ) assert result.name == "Sommar" assert result.target_weeks == 6 - assert result.recurrence == "once" # default + assert result.series_id is None # default: a standalone occurrence assert result.parent_mode == "either" # default assert result.wants_dubbeldag is False # default @@ -491,6 +516,10 @@ class TestLeaveWishService: seeded_ids: SeededIds, recurrence: str, ) -> None: + """`recurrence` is an instruction now, not a stored field: 'yearly' + materialises a series (so the returned row carries a `series_id`), + 'once' leaves a standalone occurrence. + """ result = await leave_wish_service.create( workspace_id=seeded_ids.workspace_id, name="Wish", @@ -500,9 +529,10 @@ class TestLeaveWishService: window_end_day=10, target_weeks=1, first_year=2027, + last_year=2029 if recurrence == "yearly" else None, recurrence=recurrence, ) - assert result.recurrence == recurrence + assert (result.series_id is not None) is (recurrence == "yearly") # Placeholder resolved to seeded_ids.personal_data_uuid at test time, since # parametrize case values are collected before fixtures exist. @@ -864,8 +894,15 @@ class TestLeaveWishService: async def test_create_allows_last_year_equal_first_year( self, leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: + """A one-year series is legal and produces exactly one row. + + (`recurrence='yearly'` is required for `last_year` to survive at all + now — it is the series' end-year intent, and a standalone occurrence + has no series to end. See `LeaveWishService._validate`.) + """ result = await leave_wish_service.create( workspace_id=seeded_ids.workspace_id, name="Wish", @@ -874,10 +911,13 @@ class TestLeaveWishService: window_end_month=1, window_end_day=10, target_weeks=1, + recurrence="yearly", first_year=2027, last_year=2027, ) assert result.last_year == 2027 + assert result.series_id is not None + assert len(await leave_wish_repo.get_by_series(result.series_id)) == 1 async def test_create_allows_null_last_year( self, @@ -957,7 +997,7 @@ class TestLeaveWishService: seeded_ids: SeededIds, ) -> None: """Every non-clear-flag optional field, set to a real value in one call.""" - entity = _make(workspace_id=seeded_ids.workspace_id, recurrence="once", last_year=None) + entity = _make(workspace_id=seeded_ids.workspace_id, series_id=None, last_year=None) created = await leave_wish_repo.create(entity) result = await leave_wish_service.update( @@ -982,7 +1022,7 @@ class TestLeaveWishService: assert result.window_end_month == 4 assert result.window_end_day == 20 assert result.target_weeks == 9 - assert result.recurrence == "yearly" + assert result.series_id is not None # 'yearly' materialised a series assert result.first_year == 2028 assert result.last_year == 2030 assert result.priority == 5 @@ -1007,11 +1047,19 @@ class TestLeaveWishService: leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: - entity = _make(workspace_id=seeded_ids.workspace_id, last_year=2030) + """Clearing a series' end year re-opens it to the child's 4th birthday.""" + entity = _make( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + series_id=_SERIES_ID, + first_year=2027, + last_year=2030, + ) created = await leave_wish_repo.create(entity) result = await leave_wish_service.update( leave_wish_id=created.id, + scope="series", _clear_last_year=True, ) assert result.last_year is None @@ -1296,3 +1344,423 @@ class TestLeaveWishServiceDeleteCascade: survivor = await planned_leave_repo.get_by_id(ad_hoc.id) assert survivor is not None assert survivor.wish_id is None + + +class TestLeaveWishSeriesMaterialisation: + """A repeating wish is stored as one row per year, sharing a `series_id`. + + This is the shape that makes "edit/remove just this year" possible at all: + before it, a later year had no row to address, so the frontend had to + rewrite the shared month/day pattern for every year (edit) or narrow + `first_year`/`last_year` — and split the wish in two for a middle year + (remove). Both contortions are gone. + """ + + _BASE: dict[str, Any] = dict( + name="Sommar", + window_start_month=10, + window_start_day=5, # 2026-10-05 is a Monday + window_end_month=10, + window_end_day=11, # ...10-11 the Sunday after + target_weeks=1, + ) + + async def _series( + self, + service: LeaveWishService, + repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + **overrides: Any, + ) -> tuple[LeaveWish, list[LeaveWish]]: + kwargs: dict[str, Any] = { + **self._BASE, + "recurrence": "yearly", + "first_year": 2026, + "last_year": 2029, + **overrides, + } + anchor = await service.create(workspace_id=seeded_ids.workspace_id, **kwargs) + assert anchor.series_id is not None + return anchor, await repo.get_by_series(anchor.series_id) + + async def test_create_yearly_writes_one_row_per_year_sharing_a_series_id( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + + assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029] + assert {r.series_id for r in rows} == {anchor.series_id} + assert anchor.first_year == 2026 # create() returns the first occurrence + # Every row is addressable on its own — distinct ids, one per year. + assert len({r.id for r in rows}) == 4 + + async def test_create_once_writes_a_single_standalone_row( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + created = await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE + ) + assert created.series_id is None + assert created.last_year is None + assert len(await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id)) == 1 + + async def test_the_weekday_snap_lands_in_the_stored_rows( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """Mon-Sun stays Mon-Sun, and the shifted dates are the row's own + columns — nothing re-derives a later year from the first one. + """ + _, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + + assert [(r.window_start_month, r.window_start_day) for r in rows] == [ + (10, 5), + (10, 4), + (10, 2), + (10, 8), + ] + for row in rows: + start, end = row.window() + assert start.weekday() == 0, row.first_year + assert end.weekday() == 6, row.first_year + assert (end - start).days == 6 + + async def test_flexible_series_keeps_one_bounding_range_per_year_unshifted( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """A flexible wish's fields are a bounding range, not a placement, so + materialising gives each year the SAME month/day range — the per-year + placement search inside it stays the allocator's job. + """ + _, rows = await self._series( + leave_wish_service, + leave_wish_repo, + seeded_ids, + window_start_month=3, + window_start_day=1, + window_end_month=5, + window_end_day=31, + window_mode="flexible", + ) + assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029] + assert { + (r.window_start_month, r.window_start_day, r.window_end_month, r.window_end_day) + for r in rows + } == {(3, 1, 5, 31)} + + async def test_open_ended_series_stops_at_the_childs_fourth_birthday_year( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """`last_year=None` means "until the child turns 4" — the app's whole + premise. The seeded child is born 2024-01-01, so 2028 is the last year. + """ + _, rows = await self._series( + leave_wish_service, + leave_wish_repo, + seeded_ids, + child_id=seeded_ids.child_id, + first_year=2026, + last_year=None, + ) + assert [r.first_year for r in rows] == [2026, 2027, 2028] + + async def test_editing_one_year_leaves_its_siblings_alone( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """The reason this shape exists: 2028's Easter is not 2027's, so one + year must be able to move without dragging the others with it. + """ + _, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + before = {r.first_year: r.window() for r in rows} + target = next(r for r in rows if r.first_year == 2028) + + updated = await leave_wish_service.update( + leave_wish_id=target.id, + window_start_month=11, + window_start_day=6, + window_end_month=11, + window_end_day=12, + scope="occurrence", + ) + assert updated.window() == (date(2028, 11, 6), date(2028, 11, 12)) + assert updated.series_id == target.series_id # still part of the series + + after = { + r.first_year: r.window() + for r in await leave_wish_repo.get_by_series(target.series_id) + if r.first_year != 2028 + } + assert after == {y: w for y, w in before.items() if y != 2028} + + async def test_editing_the_series_reprojects_every_year_from_the_edited_row( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """ "Every year" makes the clicked occurrence the new anchor; the other + years follow it with the same weekday-preserving projection the old + single-row shape ran at read time. + """ + _, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + target = next(r for r in rows if r.first_year == 2028) + + await leave_wish_service.update( + leave_wish_id=target.id, + name="Höstlov", + window_start_month=11, + window_start_day=6, # 2028-11-06 is a Monday + window_end_month=11, + window_end_day=12, + scope="series", + ) + + assert target.series_id is not None + after = await leave_wish_repo.get_by_series(target.series_id) + assert [r.first_year for r in after] == [2026, 2027, 2028, 2029] + assert {r.name for r in after} == {"Höstlov"} + for row in after: + start, end = row.window() + assert start.weekday() == 0, row.first_year + assert end.weekday() == 6, row.first_year + assert start.month == 11 + assert (end - start).days == 6 + + async def test_extending_last_year_adds_the_missing_rows( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + assert len(rows) == 4 + + await leave_wish_service.update(leave_wish_id=anchor.id, last_year=2031, scope="series") + + assert anchor.series_id is not None + after = await leave_wish_repo.get_by_series(anchor.series_id) + assert [r.first_year for r in after] == [2026, 2027, 2028, 2029, 2030, 2031] + assert {r.last_year for r in after} == {2031} + + async def test_shortening_last_year_deletes_the_rows_past_it( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + dropped = next(r for r in rows if r.first_year == 2029) + orphan_candidate = await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=date(2029, 10, 9), + day_type="sjukpenning", + is_dubbeldag=False, + wish_id=dropped.id, + is_pinned=False, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + + await leave_wish_service.update(leave_wish_id=anchor.id, last_year=2027, scope="series") + + assert anchor.series_id is not None + after = await leave_wish_repo.get_by_series(anchor.series_id) + assert [r.first_year for r in after] == [2026, 2027] + # The dropped years take their planned days with them, exactly like + # deleting a period does. + assert await planned_leave_repo.get_by_id(orphan_candidate.id) is None + + async def test_changing_last_year_on_one_occurrence_is_rejected( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """`last_year` belongs to the series, not to one year of it — silently + desyncing the mirror would be worse than a 422. + """ + _, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + target = next(r for r in rows if r.first_year == 2028) + + with pytest.raises(ValidationError, match="series"): + await leave_wish_service.update( + leave_wish_id=target.id, last_year=2031, scope="occurrence" + ) + + async def test_making_a_standalone_wish_repeat_materialises_around_it( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + created = await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE + ) + assert created.series_id is None + + updated = await leave_wish_service.update( + leave_wish_id=created.id, recurrence="yearly", last_year=2028 + ) + assert updated.series_id is not None + rows = await leave_wish_repo.get_by_series(updated.series_id) + assert [r.first_year for r in rows] == [2026, 2027, 2028] + assert rows[0].id == created.id # the original row anchors the series + + async def test_stopping_a_series_keeps_the_clicked_year_and_drops_the_rest( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + _, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + keeper = next(r for r in rows if r.first_year == 2028) + + updated = await leave_wish_service.update( + leave_wish_id=keeper.id, recurrence="once", scope="series" + ) + assert updated.series_id is None + assert updated.last_year is None + + remaining = await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id) + assert [r.id for r in remaining] == [keeper.id] + + async def test_delete_occurrence_removes_only_that_year( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """What used to require narrowing the range — or splitting the wish in + two around a middle year — is now one plain row delete. + """ + anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + middle = next(r for r in rows if r.first_year == 2028) + + await leave_wish_service.delete(middle.id, scope="occurrence") + + assert anchor.series_id is not None + after = await leave_wish_repo.get_by_series(anchor.series_id) + assert [r.first_year for r in after] == [2026, 2027, 2029] + + async def test_delete_series_removes_every_year( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + _, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + middle = next(r for r in rows if r.first_year == 2028) + + await leave_wish_service.delete(middle.id, scope="series") + + assert await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id) == [] + + async def test_delete_series_on_a_standalone_wish_removes_just_it( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """ "This year" and "every year" coincide on a one-off occurrence.""" + created = await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE + ) + await leave_wish_service.delete(created.id, scope="series") + assert await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id) == [] + + async def test_an_unknown_scope_is_rejected( + self, + leave_wish_service: LeaveWishService, + seeded_ids: SeededIds, + ) -> None: + created = await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE + ) + with pytest.raises(ValidationError, match="scope"): + await leave_wish_service.update(leave_wish_id=created.id, scope="everything") + with pytest.raises(ValidationError, match="scope"): + await leave_wish_service.delete(created.id, scope="everything") + + async def test_a_removed_year_can_be_put_back_into_its_series( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """The calendar's undo of "remove this year" must restore the year as + part of the repeating wish, not as a detached one-off a later "every + year" edit would miss. + """ + anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + assert anchor.series_id is not None + removed = next(r for r in rows if r.first_year == 2028) + await leave_wish_service.delete(removed.id, scope="occurrence") + + restored = await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, + first_year=removed.first_year, + window_start_month=removed.window_start_month, + window_start_day=removed.window_start_day, + window_end_month=removed.window_end_month, + window_end_day=removed.window_end_day, + target_weeks=removed.target_weeks, + name=removed.name, + series_id=anchor.series_id, + ) + assert restored.series_id == anchor.series_id + assert restored.last_year == anchor.last_year # the mirror stays consistent + after = await leave_wish_repo.get_by_series(anchor.series_id) + assert [r.first_year for r in after] == [2026, 2027, 2028, 2029] + + async def test_an_unknown_series_id_on_create_is_rejected( + self, + leave_wish_service: LeaveWishService, + seeded_ids: SeededIds, + ) -> None: + with pytest.raises(ValidationError, match="series_id"): + await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, + first_year=2026, + series_id=uuid4(), + **self._BASE, + ) + + async def test_series_id_cannot_be_combined_with_recurrence_yearly( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + anchor, _ = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + with pytest.raises(ValidationError, match="series_id"): + await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, + first_year=2035, + recurrence="yearly", + last_year=2036, + series_id=anchor.series_id, + **self._BASE, + ) diff --git a/backend/tests/unit/test_leave_wish_router.py b/backend/tests/unit/test_leave_wish_router.py index 0fcdbdf..f35ee4f 100644 --- a/backend/tests/unit/test_leave_wish_router.py +++ b/backend/tests/unit/test_leave_wish_router.py @@ -433,3 +433,124 @@ class TestLeaveWishWantsDubbeldagRoundTrip: assert update_response.status_code == 200 assert update_response.json()["wants_dubbeldag"] is True + + +class TestLeaveWishScopeQueryParam: + """`?scope=series` widens PUT/DELETE from one year to the whole series. + + The calendar's period popover offers "this year" vs "every year"; the + scope has to reach the service or the choice is decorative. + """ + + async def _workspace(self, connection: aiosqlite.Connection) -> Workspace: + now = datetime.now() + ws = await SQLiteWorkspaceRepository(connection).create( + Workspace(id=uuid4(), name="Familj", created_at=now) + ) + await SQLitePersonalDataRepository(connection).create( + PersonalData( + uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now + ) + ) + return ws + + async def _create_series(self, client: AsyncClient, ws: Workspace) -> list[dict[str, object]]: + first_year = date.today().year + 1 + response = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "name": "Höstlov", + "window_start_month": 10, + "window_start_day": 5, + "window_end_month": 10, + "window_end_day": 11, + "target_weeks": 1, + "recurrence": "yearly", + "first_year": first_year, + "last_year": first_year + 2, + }, + ) + assert response.status_code == 201, response.text + assert response.json()["series_id"] is not None + listed = await client.get(f"/api/leave-wishes/{ws.id}") + assert listed.status_code == 200, listed.text + return sorted(listed.json(), key=lambda row: row["first_year"]) + + async def test_create_yearly_returns_a_series_and_lists_one_row_per_year( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + ws = await self._workspace(connection) + rows = await self._create_series(client, ws) + + first_year = date.today().year + 1 + assert [r["first_year"] for r in rows] == [first_year, first_year + 1, first_year + 2] + assert len({r["series_id"] for r in rows}) == 1 + # `recurrence` is an instruction, not a response field any more. + assert "recurrence" not in rows[0] + + async def test_update_defaults_to_this_year_only( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + ws = await self._workspace(connection) + rows = await self._create_series(client, ws) + + response = await client.put( + f"/api/leave-wishes/{rows[1]['id']}", json={"name": "Bara detta år"} + ) + assert response.status_code == 200, response.text + + after = sorted( + (await client.get(f"/api/leave-wishes/{ws.id}")).json(), key=lambda r: r["first_year"] + ) + assert [r["name"] for r in after] == ["Höstlov", "Bara detta år", "Höstlov"] + + async def test_update_with_scope_series_applies_to_every_year( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + ws = await self._workspace(connection) + rows = await self._create_series(client, ws) + + response = await client.put( + f"/api/leave-wishes/{rows[1]['id']}?scope=series", json={"name": "Alla år"} + ) + assert response.status_code == 200, response.text + + after = (await client.get(f"/api/leave-wishes/{ws.id}")).json() + assert {r["name"] for r in after} == {"Alla år"} + assert len(after) == 3 + + async def test_delete_defaults_to_this_year_only( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + ws = await self._workspace(connection) + rows = await self._create_series(client, ws) + + response = await client.delete(f"/api/leave-wishes/{rows[1]['id']}") + assert response.status_code == 204, response.text + + after = (await client.get(f"/api/leave-wishes/{ws.id}")).json() + assert [r["id"] for r in sorted(after, key=lambda r: r["first_year"])] == [ + rows[0]["id"], + rows[2]["id"], + ] + + async def test_delete_with_scope_series_removes_every_year( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + ws = await self._workspace(connection) + rows = await self._create_series(client, ws) + + response = await client.delete(f"/api/leave-wishes/{rows[1]['id']}?scope=series") + assert response.status_code == 204, response.text + + assert (await client.get(f"/api/leave-wishes/{ws.id}")).json() == [] + + async def test_an_unknown_scope_is_a_422( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + ws = await self._workspace(connection) + rows = await self._create_series(client, ws) + + response = await client.delete(f"/api/leave-wishes/{rows[0]['id']}?scope=everything") + assert response.status_code == 422, response.text diff --git a/backend/tests/unit/test_planning_service.py b/backend/tests/unit/test_planning_service.py index 6b6868f..cecdfbd 100644 --- a/backend/tests/unit/test_planning_service.py +++ b/backend/tests/unit/test_planning_service.py @@ -31,7 +31,6 @@ def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish: window_end_month=6, window_end_day=30, target_weeks=1, - recurrence="once", first_year=2026, last_year=None, priority=1, diff --git a/backend/tests/unit/test_planning_service_apply.py b/backend/tests/unit/test_planning_service_apply.py index d70a210..6b3a403 100644 --- a/backend/tests/unit/test_planning_service_apply.py +++ b/backend/tests/unit/test_planning_service_apply.py @@ -37,7 +37,6 @@ def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish: window_end_month=6, window_end_day=30, target_weeks=1, - recurrence="once", first_year=2026, last_year=None, priority=1, diff --git a/backend/tests/unit/test_planning_service_sync.py b/backend/tests/unit/test_planning_service_sync.py index 0557084..53ea9dd 100644 --- a/backend/tests/unit/test_planning_service_sync.py +++ b/backend/tests/unit/test_planning_service_sync.py @@ -40,7 +40,6 @@ def _make_wish(workspace_id, child_id, **overrides) -> LeaveWish: window_end_month=2, window_end_day=7, target_weeks=1, - recurrence="once", first_year=2027, last_year=None, priority=1, diff --git a/backend/tests/unit/test_services/test_allocation_input_loader.py b/backend/tests/unit/test_services/test_allocation_input_loader.py index e56aa54..aa69714 100644 --- a/backend/tests/unit/test_services/test_allocation_input_loader.py +++ b/backend/tests/unit/test_services/test_allocation_input_loader.py @@ -284,7 +284,6 @@ class TestAllocationInputLoader: window_end_month=10, window_end_day=7, target_weeks=1, - recurrence="once", first_year=2027, last_year=None, priority=1, @@ -579,9 +578,12 @@ class TestAllocationInputLoader: planned_leave_repo: SQLitePlannedLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, ) -> None: - """A wish with a far-out `last_year` and no children yet (so there's - no age-12 hard cap to save it) can still in principle push the - horizon past even a ~30-year cache. Rather than silently falling + """A wish row anchored in a far-out year, with no children yet (so + there's no age-12 hard cap to save it), can still in principle push + the horizon past even a ~30-year cache. `LeaveWishService` clamps a + materialised series to the cached range, but a row can reach the DB + another way (a hand-written import, an older client, this test's + direct repo write), so the loader must still defend itself. Rather than silently falling back to the live `holidays` library (the original bug) or silently clamping the horizon (which would contradict the §6 decision to plan past B4 and show the squeeze), the loader must raise a @@ -604,9 +606,8 @@ class TestAllocationInputLoader: window_end_month=6, window_end_day=30, target_weeks=4, - recurrence="yearly", - first_year=2027, - last_year=2200, + first_year=2200, + last_year=None, priority=1, parent_mode="either", parent_personal_data_uuid=None, diff --git a/backend/tests/unit/test_services/test_allocation_once_and_wrap.py b/backend/tests/unit/test_services/test_allocation_once_and_wrap.py index c102187..0ffea1f 100644 --- a/backend/tests/unit/test_services/test_allocation_once_and_wrap.py +++ b/backend/tests/unit/test_services/test_allocation_once_and_wrap.py @@ -1,13 +1,13 @@ """Regression tests for two behaviours investigated after a live bug report: -1. `recurrence: 'once'` wishes — reported as producing *zero* planned days +1. one-off (standalone, non-series) wishes — reported as producing *zero* planned days for a drag-created period, with no error/rejection surfaced anywhere. Investigation (see the task write-up) traced the live symptom to the demo seed data: the new wish's default (lowest) priority + `parent_mode: 'specific'` bound to a parent whose vacation budget for that particular year is deliberately zeroed by `scripts/seed_demo.py`. `LeaveWish .applies_to_year`/`_candidate_years`/`_expand_all_windows` all handle - `recurrence == 'once'` correctly — proven here with ample resources (the + a standalone occurrence correctly — proven here with ample resources (the window is produced and fully funded) and with a past window (correctly produces nothing, the *good* kind of "nothing"). These tests exist so that conclusion has a permanent regression guard instead of living only @@ -34,6 +34,7 @@ from tests.unit.test_services.test_allocation_service import ( _child, _parent, _wish, + _wish_series, ) @@ -53,7 +54,6 @@ class TestOnceRecurrenceProducesDays: end=(10, 9), # Mon-Fri, 5 days weeks=1, target_days=5, - recurrence="once", first_year=2026, parent_mode="specific", parent_uuid=parent.uuid, @@ -100,7 +100,6 @@ class TestOnceRecurrenceProducesDays: start=(3, 1), end=(3, 5), weeks=1, - recurrence="once", first_year=2021, parent_mode="specific", parent_uuid=parent.uuid, @@ -135,18 +134,18 @@ class TestWrappingWindowStraddlingToday: """ child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") - wish = _wish( + wishes = _wish_series( child_id=child.id, name="Jullov", start=(12, 20), end=(1, 16), # wraps into January weeks=4, - recurrence="yearly", first_year=2025, last_year=2026, # explicit, so this doesn't depend on the child's 4th birthday parent_mode="specific", parent_uuid=parent.uuid, ) + wish = wishes[0] # the 2025-anchored occurrence — the one under test today = date(2026, 1, 5) # inside the 2025-anchored window's January tail alloc_input = _build_input( today=today, @@ -158,7 +157,7 @@ class TestWrappingWindowStraddlingToday: (parent.uuid, 2025): VacationBudget(25, 0, 0), (parent.uuid, 2026): VacationBudget(25, 0, 0), }, - wishes=[wish], + wishes=wishes, ) proposal = Allocator().propose(alloc_input) diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index 264c9eb..d5c2711 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -17,11 +17,13 @@ Where a test *wants* red-day-cluster (tier-2) behaviour, it uses a window around a real holiday (Easter 2027 / Jul 2027-2028) instead. """ +from dataclasses import replace from datetime import date, timedelta +from typing import Any from uuid import UUID, uuid4 from app.config import settings -from app.entities.leave_wish import LeaveWish +from app.entities.leave_wish import LeaveWish, project_window from app.entities.planned_leave import PlannedLeaveDay from app.services.allocation_service import ( REASON_DUBBELDAG, @@ -92,7 +94,7 @@ def _wish( end: tuple[int, int] = (2, 28), weeks: int = 4, target_days: int | None = None, - recurrence: str = "once", + series_id: UUID | None = None, first_year: int = 2027, last_year: int | None = None, priority: int = 1, @@ -116,7 +118,7 @@ def _wish( window_end_day=end[1], target_weeks=weeks, target_days=target_days, - recurrence=recurrence, + series_id=series_id, first_year=first_year, last_year=last_year, priority=priority, @@ -127,6 +129,43 @@ def _wish( ) +def _wish_series(*, first_year: int, last_year: int, **kwargs: Any) -> list[LeaveWish]: + """The per-year rows `LeaveWishService` materialises for a repeating wish. + + A repeating wish is no longer one row the allocator expands; it is one row + per year, each already carrying its own dates (see + `app.entities.leave_wish`). Tests that used to build a single + `recurrence='yearly'` wish build the rows here instead, through the same + `project_window` the service uses — so the weekday snap these rows carry is + the production projection, not a copy of it. + """ + series_id = uuid4() + anchor = _wish(first_year=first_year, last_year=last_year, series_id=series_id, **kwargs) + rows = [anchor] + for year in range(first_year + 1, last_year + 1): + start, end = project_window( + start_month=anchor.window_start_month, + start_day=anchor.window_start_day, + end_month=anchor.window_end_month, + end_day=anchor.window_end_day, + anchor_year=first_year, + year=year, + window_mode=anchor.window_mode, + ) + rows.append( + replace( + anchor, + id=uuid4(), + first_year=year, + window_start_month=start.month, + window_start_day=start.day, + window_end_month=end.month, + window_end_day=end.day, + ) + ) + return rows + + def _build_input( *, today: date, @@ -1523,13 +1562,12 @@ class TestCase21FlexibleYearlyWishTracksMovingEaster: def test_each_year_lands_on_that_years_easter_adjacent_dates(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") - wish = _wish( + wishes = _wish_series( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, - recurrence="yearly", first_year=2027, last_year=2029, window_mode="flexible", @@ -1545,7 +1583,7 @@ class TestCase21FlexibleYearlyWishTracksMovingEaster: (parent.uuid, 2028): VacationBudget(50, 0, 0), (parent.uuid, 2029): VacationBudget(50, 0, 0), }, - wishes=[wish], + wishes=wishes, extra_holiday_years=(2028, 2029), ) @@ -2426,81 +2464,117 @@ class TestStrategyReportsDegradation: # --------------------------------------------------------------------------- -# todo.md #1 -- a `window_mode == 'fixed'` wish's recurrence is a week-based -# habit ("off Mon-Sun"), not a literal month/day. `window_for_year` must snap -# later years' naive month/day anchor onto the nearest date sharing -# `first_year`'s start weekday, and hold the window's day-span fixed instead -# of re-anchoring end_month/end_day. Flexible wishes are unaffected -- their -# four fields are a bounding range to search inside, never a placement. -# These are pure entity-level tests (no Allocator needed) since the rule -# lives entirely in `LeaveWish.window_for_year`. +# todo.md #1 -- a `window_mode == 'fixed'` repeating wish is a week-based habit +# ("off Mon-Sun"), not a literal month/day. Each later year's naive month/day +# anchor must be snapped onto the nearest date sharing the anchor year's start +# weekday, holding the window's day-span fixed instead of re-anchoring +# end_month/end_day. Flexible wishes are unaffected -- their four fields are a +# bounding range to search inside, never a placement. +# +# The snap now happens ONCE, when `LeaveWishService` materialises the series +# into per-year rows (`project_window`), and the snapped dates are what each +# row stores -- so these assert on the stored rows rather than on a projection +# re-run at read time. The dates are the same ones the old single-row shape +# produced; `_wish_series` runs the production projection, so nothing here +# re-derives the arithmetic. Still pure entity-level tests, no Allocator. # --------------------------------------------------------------------------- class TestCase29FixedYearlyWishPreservesWeekdayAlignment: def test_reported_user_case_shifts_to_the_nearest_matching_weekday(self) -> None: - """2026-10-05..10-11 is Mon-Sun. Naively recurring the same month/day + """2026-10-05..10-11 is Mon-Sun. Naively repeating the same month/day would land 2027 on Tue-Mon -- the exact bug report. The nearest date to 2027-10-05 sharing Monday is 2027-10-04, and holding the 6-day span gives 2027-10-04..10-10 (also Mon-Sun); 2028 lands on 10-02..10-08 for the same reason (verified with date.weekday() directly, not by re-deriving the arithmetic in this docstring). """ - wish = _wish( - child_id=None, - start=(10, 5), - end=(10, 11), - recurrence="yearly", - first_year=2026, - window_mode="fixed", - ) + rows = { + w.first_year: w + for w in _wish_series( + child_id=None, + start=(10, 5), + end=(10, 11), + first_year=2026, + last_year=2028, + window_mode="fixed", + ) + } + assert set(rows) == {2026, 2027, 2028} + # Every year is its own row, all in one series. + assert len({w.series_id for w in rows.values()}) == 1 + assert all(w.series_id is not None for w in rows.values()) - start_2026, end_2026 = wish.window_for_year(2026) + start_2026, end_2026 = rows[2026].window() assert (start_2026, end_2026) == (date(2026, 10, 5), date(2026, 10, 11)) assert start_2026.weekday() == 0 # Monday assert end_2026.weekday() == 6 # Sunday - start_2027, end_2027 = wish.window_for_year(2027) + start_2027, end_2027 = rows[2027].window() assert (start_2027, end_2027) == (date(2027, 10, 4), date(2027, 10, 10)) assert start_2027.weekday() == 0 assert end_2027.weekday() == 6 - start_2028, end_2028 = wish.window_for_year(2028) + start_2028, end_2028 = rows[2028].window() assert (start_2028, end_2028) == (date(2028, 10, 2), date(2028, 10, 8)) assert start_2028.weekday() == 0 assert end_2028.weekday() == 6 + def test_the_snap_is_stored_on_the_row_not_recomputed(self) -> None: + """The whole point of materialising: the shifted dates are the row's + own month/day columns, so a later per-year edit has somewhere to go + and nothing re-derives 2027 from 2026 behind the user's back. + """ + rows = { + w.first_year: w + for w in _wish_series( + child_id=None, + start=(10, 5), + end=(10, 11), + first_year=2026, + last_year=2027, + window_mode="fixed", + ) + } + assert (rows[2027].window_start_month, rows[2027].window_start_day) == (10, 4) + assert (rows[2027].window_end_month, rows[2027].window_end_day) == (10, 10) + def test_first_year_is_returned_unshifted(self) -> None: - wish = _wish( + rows = _wish_series( child_id=None, start=(10, 5), end=(10, 11), - recurrence="yearly", first_year=2026, + last_year=2028, window_mode="fixed", ) - assert wish.window_for_year(2026) == (date(2026, 10, 5), date(2026, 10, 11)) + assert rows[0].first_year == 2026 + assert rows[0].window() == (date(2026, 10, 5), date(2026, 10, 11)) def test_new_year_wrapping_fixed_wish_still_wraps(self) -> None: """Jullov 12-20..01-06 (Sun -> Wed, a 17-day span) must keep landing - its end in `year + 1` relative to its own anchor year after the - weekday shift, exactly like the unshifted (first_year) case. + its end in `first_year + 1` relative to its own row's year after the + weekday shift, exactly like the unshifted (anchor year) case. """ - wish = _wish( - child_id=None, - start=(12, 20), - end=(1, 6), - recurrence="yearly", - first_year=2026, - window_mode="fixed", - ) - assert wish.window_wraps() is True + rows = { + w.first_year: w + for w in _wish_series( + child_id=None, + start=(12, 20), + end=(1, 6), + first_year=2026, + last_year=2027, + window_mode="fixed", + ) + } + assert rows[2026].window_wraps() is True + assert rows[2027].window_wraps() is True - start_2026, end_2026 = wish.window_for_year(2026) + start_2026, end_2026 = rows[2026].window() assert start_2026 == date(2026, 12, 20) assert end_2026 == date(2027, 1, 6) - start_2027, end_2027 = wish.window_for_year(2027) + start_2027, end_2027 = rows[2027].window() assert start_2027 == date(2027, 12, 19) assert end_2027 == date(2028, 1, 5) assert (end_2027 - start_2027) == (end_2026 - start_2026) @@ -2511,18 +2585,23 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment: """A flexible wish's four fields describe a bounding range to search inside, not a placement -- no weekday shift, ever, including for Season presets which must stay byte-identical after this change. + Each year still gets its OWN row (carrying that year's range); the + per-year placement search inside it is the allocator's job. """ - wish = _wish( - child_id=None, - start=(3, 1), - end=(5, 31), - recurrence="yearly", - first_year=2026, - window_mode="flexible", - ) - assert wish.window_for_year(2026) == (date(2026, 3, 1), date(2026, 5, 31)) - assert wish.window_for_year(2027) == (date(2027, 3, 1), date(2027, 5, 31)) - assert wish.window_for_year(2028) == (date(2028, 3, 1), date(2028, 5, 31)) + rows = { + w.first_year: w + for w in _wish_series( + child_id=None, + start=(3, 1), + end=(5, 31), + first_year=2026, + last_year=2028, + window_mode="flexible", + ) + } + assert rows[2026].window() == (date(2026, 3, 1), date(2026, 5, 31)) + assert rows[2027].window() == (date(2027, 3, 1), date(2027, 5, 31)) + assert rows[2028].window() == (date(2028, 3, 1), date(2028, 5, 31)) # --------------------------------------------------------------------------- @@ -2863,13 +2942,12 @@ class TestCase34PartialFundingAcrossYearsShortfallNotFailure: # August has no Swedish red days nearby, so tier-2 never fires. child = _child(birth_date=date(2027, 9, 1)) # fourth birthday well after 2030 parent = _parent(salary_type="hourly") - wish = _wish( + wishes = _wish_series( child_id=child.id, name="Sportlov", start=(8, 2), end=(8, 8), weeks=1, - recurrence="yearly", first_year=2027, last_year=2030, priority=1, @@ -2896,7 +2974,7 @@ class TestCase34PartialFundingAcrossYearsShortfallNotFailure: # shortfall, not a full-window one. pools={(parent.uuid, child.id): DayPool(18, 0, 60)}, vacation={(parent.uuid, y): VacationBudget(0, 0, 0) for y in range(2026, 2031)}, - wishes=[wish, sibling], + wishes=[*wishes, sibling], # `_build_input` only auto-merges holidays for # {today.year, horizon_end.year, horizon_end.year + 1} = # {2027, 2030, 2031} -- without this, 2028/2029 would be missing @@ -2907,7 +2985,8 @@ class TestCase34PartialFundingAcrossYearsShortfallNotFailure: proposal = Allocator().propose(alloc_input) - by_year = {o.year: o for o in proposal.outcomes if o.wish_id == wish.id} + series_ids = {w.id for w in wishes} + by_year = {o.year: o for o in proposal.outcomes if o.wish_id in series_ids} assert set(by_year) == {2027, 2028, 2029, 2030} for y in (2027, 2028, 2029): @@ -2930,7 +3009,7 @@ class TestCase34PartialFundingAcrossYearsShortfallNotFailure: # A usable plan, not a refusal: the fourth year still has concrete # `ProposedDay`s for the days it could actually afford (2 free + 3 # sjukpenning = 5), not zero. - year4_days = [d for d in proposal.days if d.wish_id == wish.id and d.date.year == 2030] + year4_days = [d for d in proposal.days if d.wish_id in series_ids and d.date.year == 2030] assert len(year4_days) == 5 assert sum(1 for d in year4_days if d.day_type == "sjukpenning") == 3 @@ -2951,13 +3030,12 @@ class TestCase35ShiftRemedyFindsMovingEaster: def test_widened_search_finds_that_years_easter_cluster(self) -> None: child = _child(birth_date=date(2026, 1, 1)) # fourth birthday 2030 -- past both years parent = _parent(salary_type="hourly") - wish = _wish( + wishes = _wish_series( child_id=child.id, name="Vårvecka", start=(3, 22), end=(3, 28), weeks=1, - recurrence="yearly", first_year=2027, last_year=2028, ) @@ -2976,17 +3054,19 @@ class TestCase35ShiftRemedyFindsMovingEaster: (parent.uuid, 2027): VacationBudget(0, 0, 0), (parent.uuid, 2028): VacationBudget(0, 0, 0), }, - wishes=[wish], + wishes=wishes, ) proposal = Allocator().propose(alloc_input) by_year = {o.year: o for o in proposal.outcomes} - # The wish's own placement each year, from the production entity -- - # NOT hand-derived -- confirms the weekday-preserving snap moves the - # naive 2028 anchor from Mon 3/20 rather than literally 3/22. - assert wish.window_for_year(2027) == (date(2027, 3, 22), date(2027, 3, 28)) - assert wish.window_for_year(2028) == (date(2028, 3, 20), date(2028, 3, 26)) + # Each year's placement, read straight off its OWN row -- the + # weekday-preserving snap was applied once, when the series was + # materialised, and moves the naive 2028 anchor from Mon 3/20 rather + # than leaving it literally on 3/22. + rows_by_year = {w.first_year: w for w in wishes} + assert rows_by_year[2027].window() == (date(2027, 3, 22), date(2027, 3, 28)) + assert rows_by_year[2028].window() == (date(2028, 3, 20), date(2028, 3, 26)) outcome_2027 = by_year[2027] assert outcome_2027.shortfall_days > 0 @@ -3020,5 +3100,5 @@ class TestCase35ShiftRemedyFindsMovingEaster: # Both wish-name provenance fields still travel onto the remedy # (§branches test already covers this for the non-Easter case; keep # it here too since this remedy's `data` shape just grew). - assert shift_2027.data["wish_name_message_key"] == wish.name_message_key - assert shift_2028.data["wish_name_message_key"] == wish.name_message_key + assert shift_2027.data["wish_name_message_key"] == rows_by_year[2027].name_message_key + assert shift_2028.data["wish_name_message_key"] == rows_by_year[2028].name_message_key diff --git a/backend/tests/unit/test_services/test_allocation_swap_fairness.py b/backend/tests/unit/test_services/test_allocation_swap_fairness.py index 962fa26..14e0c29 100644 --- a/backend/tests/unit/test_services/test_allocation_swap_fairness.py +++ b/backend/tests/unit/test_services/test_allocation_swap_fairness.py @@ -77,7 +77,6 @@ def _jullov_wrap_scenario(*, parent_a_starved: bool): start=(12, 20), end=(1, 16), # wraps into January weeks=4, - recurrence="once", first_year=2027, parent_mode="either", ) diff --git a/backend/tests/unit/test_services/test_tips_service.py b/backend/tests/unit/test_services/test_tips_service.py index 36b37d5..f74fdd7 100644 --- a/backend/tests/unit/test_services/test_tips_service.py +++ b/backend/tests/unit/test_services/test_tips_service.py @@ -127,10 +127,10 @@ def _make_break( ) -> LeaveWish: """Build a single-year LeaveWish equivalent to the old BreakPeriod(start, end). - `recurrence="once"` + `first_year=start.year` pins the wish to the single - concrete window (start, end) — `window_for_year` reproduces those exact - dates as long as `end >= start` in the same year (true for every caller - here), so this is a drop-in replacement for the retired BreakPeriod shape. + Every wish row is one occurrence now, so `first_year=start.year` plus the + month/day pair IS the window: `LeaveWish.window()` reads back exactly + (start, end) as long as `end >= start` in the same year (true for every + caller here). A drop-in replacement for the retired BreakPeriod shape. """ return LeaveWish( id=uuid4(), @@ -142,9 +142,7 @@ def _make_break( window_end_month=end.month, window_end_day=end.day, target_weeks=target_weeks, - recurrence="once", first_year=start.year, - last_year=start.year, priority=1, parent_mode="either", parent_personal_data_uuid=None, diff --git a/backend/tests/unit/test_workspace_service.py b/backend/tests/unit/test_workspace_service.py index e4683a5..bb44638 100644 --- a/backend/tests/unit/test_workspace_service.py +++ b/backend/tests/unit/test_workspace_service.py @@ -259,7 +259,6 @@ class TestRemoveMember: window_end_month=8, window_end_day=31, target_weeks=4, - recurrence="yearly", first_year=2026, last_year=None, priority=1, diff --git a/frontend/e2e/tests/drag-create-delete.spec.ts b/frontend/e2e/tests/drag-create-delete.spec.ts index 9577f95..130c526 100644 --- a/frontend/e2e/tests/drag-create-delete.spec.ts +++ b/frontend/e2e/tests/drag-create-delete.spec.ts @@ -178,8 +178,8 @@ test('a drag saved without opening Advanced creates a planner-funded wish, and t // Open the wish-linked band. Its own three-way remove: "this day" cannot // be expressed for a wish (it would just reappear on the next sync) and - // is disabled; "this period" on a `recurrence: 'once'` wish (there is - // only one occurrence) removes the whole thing, same as "every year". + // is disabled; "this period" on a standalone (non-repeating) wish — one + // row, no series — removes the whole thing, same as "every year". await wishBand.first().click() await expect(page.getByTestId('period-popover__content')).toBeVisible() await expect(page.getByTestId('period-delete-day-button')).toBeDisabled() diff --git a/frontend/src/components/calendar/PeriodPopover.test.tsx b/frontend/src/components/calendar/PeriodPopover.test.tsx index 91426e5..b12048a 100644 --- a/frontend/src/components/calendar/PeriodPopover.test.tsx +++ b/frontend/src/components/calendar/PeriodPopover.test.tsx @@ -94,8 +94,10 @@ describe('PeriodPopover', () => { windowEndMonth: 7, windowEndDay: 26, firstYear: 2028, + // One year of a repeating wish: the series link is what makes it + // "repeat" now, and what makes the this-year/every-year choice real. + seriesId: 'series-1', lastYear: null, - recurrence: 'yearly', parentMode: 'either', }) @@ -141,7 +143,7 @@ describe('PeriodPopover', () => { windowEndMonth: 7, windowEndDay: 26, firstYear: 2026, - recurrence: 'yearly', + seriesId: 'series-1', }) render( { }) it('re-checking "until age 4" hides the last-year input and clears it on save', async () => { + // The end year is series-wide, so the flow starts by widening the scope + // — the control is disabled until then (see the scope tests above). mockOnSave.mockResolvedValue(undefined) render() + fireEvent.click(screen.getByTestId('period-scope-series')) fireEvent.click(screen.getByTestId('period-until-age4')) expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument() fireEvent.click(screen.getByTestId('period-save-button')) @@ -208,12 +213,69 @@ describe('PeriodPopover', () => { it('editing the last-year input updates the value sent on save', async () => { mockOnSave.mockResolvedValue(undefined) render() + fireEvent.click(screen.getByTestId('period-scope-series')) fireEvent.change(screen.getByTestId('period-last-year-input'), { target: { value: '2032' } }) expect(screen.getByTestId('period-last-year-input')).toHaveValue(2032) fireEvent.click(screen.getByTestId('period-save-button')) await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: 2032 }))) }) + // Per-year vs whole-series editing. Only a real choice once the + // occurrence belongs to a series — that is what materialising the years + // into their own rows bought (Easter moves, so one year has to be able to + // move without the rest). + describe('this year vs every year', () => { + it('offers the choice for an occurrence of a repeating wish', () => { + render() + expect(screen.getByTestId('period-scope-fieldset')).toBeInTheDocument() + expect(screen.getByTestId('period-scope-occurrence')).toBeChecked() + expect(screen.getByTestId('period-scope-series')).not.toBeChecked() + }) + + it('does not offer it for a one-off period, where there is nothing to choose between', () => { + render() + expect(screen.queryByTestId('period-scope-fieldset')).not.toBeInTheDocument() + }) + + it('hides it again when the user turns repeating off', () => { + render() + fireEvent.click(screen.getByTestId('period-repeat-never')) + expect(screen.queryByTestId('period-scope-fieldset')).not.toBeInTheDocument() + }) + + it('saves this year only by default', async () => { + mockOnSave.mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByTestId('period-save-button')) + await waitFor(() => + expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ scope: 'occurrence' })), + ) + }) + + it('saves every year once "Alla år" is picked', async () => { + mockOnSave.mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByTestId('period-scope-series')) + fireEvent.click(screen.getByTestId('period-save-button')) + await waitFor(() => + expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ scope: 'series' })), + ) + }) + + it('locks the series end year until the edit is series-wide', () => { + // `lastYear` belongs to the whole series, so the backend rejects a + // change to it under the this-year-only scope. Say so in the UI + // rather than let the user hit a 422. + render() + expect(screen.getByTestId('period-last-year-input')).toBeDisabled() + expect(screen.getByTestId('period-last-year-locked-hint')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('period-scope-series')) + expect(screen.getByTestId('period-last-year-input')).toBeEnabled() + expect(screen.queryByTestId('period-last-year-locked-hint')).not.toBeInTheDocument() + }) + }) + it('toggles to "Never" repeat and hides the yearly options', () => { render() fireEvent.click(screen.getByTestId('period-repeat-never')) @@ -236,6 +298,9 @@ describe('PeriodPopover', () => { parentMode: 'either', parentPersonalDataUuid: null, recurrence: 'yearly', + // Defaults to the narrow scope: an edit must not silently rewrite + // the other years of the series. + scope: 'occurrence', lastYear: null, wantsDubbeldag: false, }) diff --git a/frontend/src/components/calendar/PeriodPopover.tsx b/frontend/src/components/calendar/PeriodPopover.tsx index 1730dac..e738965 100644 --- a/frontend/src/components/calendar/PeriodPopover.tsx +++ b/frontend/src/components/calendar/PeriodPopover.tsx @@ -1,23 +1,28 @@ import { ReactNode, useEffect, useState } from 'react' -import type { LeaveWish } from '@/types' +import type { LeaveWish, LeaveWishScope } from '@/types' import { useTranslation, type TranslationKey } from '@/i18n' import { resolveWishName } from '@/utils/wishName' import './period-popover.css' export interface PeriodPopoverSavePayload { name: string - // ISO-8601 dates for the period's first (or only) occurrence. Month/day - // become the wish's recurring anchor; the start date's year becomes - // `firstYear`. See `LeaveWish.window_for_year()` (backend) for how a - // wrapping window (end sorts before start) is inferred from month/day - // alone, not from the actual year gap between these two dates. + // ISO-8601 dates for the clicked occurrence. Month/day plus the start + // date's year ARE that occurrence's window — one wish row is one year (see + // backend/app/entities/leave_wish.py). A wrapping window (end sorts before + // start) is still inferred from month/day alone, not from the actual year + // gap between these two dates. startDate: string endDate: string parentMode: 'either' | 'both' | 'specific' parentPersonalDataUuid: string | null recurrence: 'yearly' | 'once' - // Meaningful only when `recurrence === 'yearly'`. `null` = "until the - // child turns 4" (no explicit end year). + // How far the edit reaches. Only a genuine choice on an occurrence that + // already belongs to a series — `'occurrence'` rewrites just the clicked + // year, `'series'` re-projects every year from it. Making a one-off repeat + // (or a repeat stop) is inherently series-wide whatever this says. + scope: LeaveWishScope + // The series' last year; only sent when the scope makes it meaningful (see + // Calendar.tsx's `handleSavePeriod`). `null` = "until the child turns 4". lastYear: number | null // §Decision A (co-leave, opt-in dubbeldagar) — only meaningful/sent when // `parentMode === 'both'`. See `LeaveWish.wantsDubbeldag` and @@ -58,10 +63,23 @@ interface FormState { parentMode: 'either' | 'both' | 'specific' parentPersonalDataUuid: string | null recurrence: 'yearly' | 'once' + scope: LeaveWishScope lastYear: number | null wantsDubbeldag: boolean } +const EMPTY_FORM: FormState = { + name: '', + startDate: '', + endDate: '', + parentMode: 'either', + parentPersonalDataUuid: null, + recurrence: 'once', + scope: 'occurrence', + lastYear: null, + wantsDubbeldag: false, +} + /** Mirrors `LeaveWish.window_wraps()` (backend/app/entities/leave_wish.py): * whether (end_month, end_day) sorts before (start_month, start_day). */ function windowWraps(startMonth: number, startDay: number, endMonth: number, endDay: number): boolean { @@ -84,14 +102,12 @@ function formStateFromWish( t: (key: TranslationKey) => string, ): FormState { // Prefer the clicked band's actual placed dates (the `startDate`/`endDate` - // props — see `PeriodPopoverProps`'s doc comment) over recomputing from - // the wish's static recurring window. `wish.firstYear` + the window only - // describes the *first* occurrence; clicking a later-year occurrence of a - // yearly wish (or any occurrence of a flexible wish, whose placement can - // drift inside its bounding range) needs the real placed dates, not the - // anchor year's. Fall back to the window-derived dates if the caller - // didn't supply real placed dates (defensive — callers are expected to - // always pass them). + // props — see `PeriodPopoverProps`'s doc comment) over the row's stored + // window. The row IS one year, so the two agree for a fixed wish; a + // flexible wish's placement still drifts inside its bounding range, so the + // real placed dates are the ones to show. Fall back to the window-derived + // dates if the caller didn't supply real placed dates (defensive — callers + // are expected to always pass them). const wraps = windowWraps(wish.windowStartMonth, wish.windowStartDay, wish.windowEndMonth, wish.windowEndDay) const endYear = wraps ? wish.firstYear + 1 : wish.firstYear return { @@ -100,7 +116,12 @@ function formStateFromWish( endDate: endDate || formatISO(endYear, wish.windowEndMonth, wish.windowEndDay), parentMode: wish.parentMode, parentPersonalDataUuid: wish.parentPersonalDataUuid, - recurrence: wish.recurrence, + // "Does this repeat?" is "does it belong to a series?" now — there is no + // `recurrence` field on the wire any more. + recurrence: wish.seriesId !== null ? 'yearly' : 'once', + // Default to the narrow, newly-possible choice: an edit should not + // silently rewrite every other year. + scope: 'occurrence', lastYear: wish.lastYear, wantsDubbeldag: wish.wantsDubbeldag, } @@ -119,16 +140,7 @@ export function PeriodPopover({ onClose, }: PeriodPopoverProps): ReactNode { const { t } = useTranslation() - const [form, setForm] = useState({ - name: '', - startDate, - endDate, - parentMode: 'either', - parentPersonalDataUuid: null, - recurrence: 'once', - lastYear: null, - wantsDubbeldag: false, - }) + const [form, setForm] = useState({ ...EMPTY_FORM, startDate, endDate }) const [localError, setLocalError] = useState(null) const [deleteScope, setDeleteScope] = useState(null) @@ -136,16 +148,7 @@ export function PeriodPopover({ if (wish) { setForm(formStateFromWish(wish, startDate, endDate, t)) } else { - setForm({ - name: '', - startDate, - endDate, - parentMode: 'either', - parentPersonalDataUuid: null, - recurrence: 'once', - lastYear: null, - wantsDubbeldag: false, - }) + setForm({ ...EMPTY_FORM, startDate, endDate }) } setLocalError(null) setDeleteScope(null) @@ -154,6 +157,15 @@ export function PeriodPopover({ if (!isOpen) return null const parentSelectValue = form.parentMode === 'specific' ? (form.parentPersonalDataUuid ?? '') : form.parentMode + // The "this year" vs "every year" choice is only real for an occurrence + // that already has siblings to spare (or to change). Turning repeat on or + // off is series-wide by definition, so no choice is offered for it. + const isSeries = wish?.seriesId != null + const showScopeChoice = isSeries && form.recurrence === 'yearly' + // The end year belongs to the whole series, so it can only be moved with + // the "every year" scope — the backend rejects it otherwise rather than + // let one row's mirror drift out of step with its siblings'. + const lastYearLocked = showScopeChoice && form.scope !== 'series' const handleSave = async () => { if (!wish) return @@ -166,6 +178,7 @@ export function PeriodPopover({ parentMode: form.parentMode, parentPersonalDataUuid: form.parentMode === 'specific' ? form.parentPersonalDataUuid : null, recurrence: form.recurrence, + scope: isSeries && form.recurrence === 'yearly' ? form.scope : 'occurrence', lastYear: form.recurrence === 'yearly' ? form.lastYear : null, wantsDubbeldag: form.parentMode === 'both' ? form.wantsDubbeldag : false, }) @@ -365,6 +378,35 @@ export function PeriodPopover({ + {showScopeChoice && ( +
+ {t('calendar.periodEditScopeLabel')} + + +

{t('calendar.periodEditScopeHint')}

+
+ )} + {form.recurrence === 'yearly' && (
+ {lastYearLocked && ( +

+ {t('calendar.periodLastYearNeedsSeriesHint')} +

+ )} {form.lastYear !== null && (