"""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, timedelta from uuid import UUID def _shift_to_weekday(naive: date, target_weekday: int) -> date: """Nearest date to `naive` whose `weekday()` is `target_weekday`. Weekdays repeat every 7 days, so the shift is always in [-3, +3] days — built with `timedelta`, never by mutating month/day ints (Feb 29 and month-end would misbehave otherwise). Ties can't actually arise (7 is odd, so no naive date is equidistant between two candidates), but the comparison key resolves one in favour of the earlier date regardless, to stay defensive. """ forward = (target_weekday - naive.weekday()) % 7 # 0..6, shift going forward shift = min((forward, forward - 7), key=lambda d: (abs(d), d)) return naive + timedelta(days=shift) @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" — persisted, possibly-translated display text # 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' # 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' # §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 # when the user has explicitly requested them for this wish. NOTE: no # frontend exposes this yet -- the period editor's dubbeldag checkbox is # not built, and there is no `wantsDubbeldag` in the TS types, so today # the flag is only reachable through the API. When that UI lands it must # show the checkbox only where `WindowOutcome.dubbeldagar_legally_available` # says they legally could apply -- see allocation_service.py. Defaults to False so every # existing wish (and every wish that never opts in) gets the new # PL+vacation co-leave behaviour instead of dubbeldagar. Meaningless # outside `parent_mode == 'both'` -- `LeaveWishService._validate` rejects # setting it otherwise. wants_dubbeldag: bool = False # Provenance of `name`: when a wish is created from a preset (Sommar, # Jullov, Höstlov, ...) this holds the i18n message key that produced the # translated string stored in `name` (e.g. "wishEditor.presetSommar"). # Nullable — free-typed names, and every row created before this field # existed, have no key and just show `name` verbatim. Cleared by # `LeaveWishService.update` the moment the user edits `name`, since a # user-typed name is no longer a preset label and must never silently # revert to one. name_message_key: str | None = None # A calendar-drawn period (e.g. dragging a band on the calendar UI) is not # a whole number of weeks. NULL (every row created before this field # existed, and any wish still specified in weeks) falls back to # `target_weeks * 7` — see `target_span_days()`. target_days: int | None = None created_at: datetime | None = None updated_at: datetime | None = None def target_span_days(self) -> int: """Wanted duration in days. `target_days` wins when set; otherwise weeks * 7.""" return self.target_days if self.target_days is not None else self.target_weeks * 7 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. `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. """ 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 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