Files
F-r-ldraledigplannerare/backend/app/entities/leave_wish.py
T
jacobh2 dc4c0a5edd Show preset wish names in the current UI language
Preset wishes were created with `name: t(preset.labelKey)`, so the
translated string was persisted as the wish's name. A wish created while
the UI was Swedish stayed "Jul" in English forever -- the Swedish text
was the data, so no display-layer change could fix it.

Wish names are user-editable free text, so `name` cannot simply be
translated at render time. Migration 007 adds a nullable
`leave_wish.name_message_key` recording where a name came from: preset
wishes store the i18n key alongside the translated literal, and the UI
renders the key when present.

The key is cleared server-side the moment `name` actually changes,
compared against the stored value rather than trusting the caller, so no
client can forget to clear it and silently revert a user's own name to a
preset label.

An unknown key (a stale row, a renamed key) falls back to the stored
literal via an isTranslationKey guard, so a bad key can never crash the
page or leak a raw key to the user. Rows predating this column keep
displaying their stored name.

_FULL_SCHEMA in db/connection.py is updated in step with the migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 22:32:54 +02:00

108 lines
4.7 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
from uuid import UUID
@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'
# Per-window override of workspace planning_goal. Nullable, no UI in v1 —
# the field exists now so enabling it later needs no migration.
goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None
# 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'
# 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
created_at: datetime | None = None
updated_at: datetime | None = None
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.
"""
start = date(year, self.window_start_month, self.window_start_day)
end_year = year + 1 if self.window_wraps() else year
end = date(end_year, self.window_end_month, self.window_end_day)
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