"""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. 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. """ from dataclasses import dataclass from datetime import date, datetime from uuid import UUID @dataclass class LeaveWish: """A family's recurring intent to be home for N weeks inside a yearly window.""" id: UUID workspace_id: UUID child_id: UUID | None # None = family-wide; set = tied to that child's pools name: str # "Sommar", "Jullov" # Recurring anchor stored as month/day ints (SQLite-friendly, no custom type). 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 priority: int # 1 = highest; drives best-effort fill order parent_mode: str # 'either' | 'both' | 'specific' parent_personal_data_uuid: UUID | None # required when parent_mode == 'specific' # Per-window override of workspace planning_goal. Nullable, no UI in v1 — # the field exists now so enabling it later needs no migration. goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None # PLANNING_ENGINE_SPEC.md §10.1 — 'fixed' (default): window_start_*/window_end_* # 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. window_mode: str = "fixed" # 'fixed' | 'flexible' created_at: datetime | None = None updated_at: datetime | None = None def window_wraps(self) -> bool: """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`. """ return (self.window_end_month, self.window_end_day) < ( self.window_start_month, self.window_start_day, ) def window_for_year(self, year: int) -> tuple[date, date]: """Concrete (start_date, end_date) for this wish's window anchored at `year`. `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. """ start = date(year, self.window_start_month, self.window_start_day) end_year = year + 1 if self.window_wraps() else year end = 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