ad0e3c0b33
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
243 lines
12 KiB
Python
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
|