Files
F-r-ldraledigplannerare/backend/app/entities/leave_wish.py
T
jacobh2 224fe7ae3e Keep a repeating week on the weekday it was taken on
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
2026-09-19 14:03:21 +02:00

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