Files
F-r-ldraledigplannerare/backend/app/entities/leave_wish.py
T
jacobh2 ad0e3c0b33 Keep a repeating week on its weekday when the planner picks the dates
A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.

project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.

_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.

_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.

Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.

Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.

Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
2026-09-20 12:04:20 +02:00

243 lines
12 KiB
Python

"""Domain entity: LeaveWish.
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).
**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
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)
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.
Both `window_mode`s 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.
For `window_mode == 'fixed'` the span being held fixed IS the placement:
the result is the exact dates the family is home. For `window_mode ==
'flexible'` (§10.2) the result is still a BOUNDING RANGE the allocator
searches inside, not a placement — but the range itself must keep its
weekday alignment year over year, or a week-shaped bound (e.g. Mon-Sun)
degenerates into an arbitrary weekday every other year, which is exactly
as wrong for a search bound as for a fixed placement (a week-shaped bound
should stay week-shaped). The snap never changes the bound's *length* —
only `_shift_to_weekday`'s ±3-day slide of the start, with the same
day-span held fixed — so the search space `resolve_flexible_window`
explores never shrinks. `window_mode` is otherwise unused here; kept as a
parameter because both modes now take the identical snap path and every
caller already has the value at hand — removing it would just make call
sites pass a value nobody reads.
"""
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 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:
"""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
# 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
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'
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. Both modes get the same
# per-year weekday snap at materialisation (`project_window`): a fixed
# window's exact placement keeps its weekday, AND a flexible window's
# bounding range keeps its weekday alignment (so a week-shaped bound
# stays week-shaped every year) while its span — and so its search
# space — stays exactly as wide as the anchor year's.
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
# 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*
`first_year`.
"""
return window_wraps_month_day(
self.window_start_month,
self.window_start_day,
self.window_end_month,
self.window_end_day,
)
def window(self) -> tuple[date, date]:
"""This occurrence's concrete (start_date, end_date), inclusive.
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.
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.
"""
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