224fe7ae3e
A week off is a week off — Monday to Sunday this year means Monday to Sunday next year, not the same two calendar dates landing mid-week. A fixed yearly wish now snaps each later year's start to the nearest date carrying the first year's weekday and holds the span, so 5-11 October 2026 recurs as 4-10 October 2027. Flexible wishes are untouched: their four date fields are a bounding range the allocator searches inside, not a placement, so a weekday shift there would mean nothing. The first year is always returned unshifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
171 lines
8.3 KiB
Python
171 lines
8.3 KiB
Python
"""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
|