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 && (