Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
"""Business logic for leave wishes (family recurring home-together intent).
|
||||
|
||||
See PLANNING_ENGINE_SPEC.md §2.1. This service owns the validation the
|
||||
allocator (Phase B) depends on: valid recurrence/parent_mode/goal_override,
|
||||
sane month/day ranges, and the parent_mode <-> parent_personal_data_uuid
|
||||
pairing rule.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.entities.leave_wish import LeaveWish
|
||||
from app.repositories.leave_wish_repository import LeaveWishRepository
|
||||
from app.utils.errors import NotFoundError, ValidationError
|
||||
|
||||
_VALID_RECURRENCE = {"yearly", "once"}
|
||||
_VALID_PARENT_MODE = {"either", "both", "specific"}
|
||||
_VALID_GOAL_OVERRIDE = {"max_time", "max_money", "balanced"}
|
||||
_VALID_WINDOW_MODE = {"fixed", "flexible"}
|
||||
|
||||
# A leap year, so day=29 for month=2 (Feb 29) is accepted when validating ranges.
|
||||
_LEAP_REFERENCE_YEAR = 2028
|
||||
|
||||
|
||||
class LeaveWishService:
|
||||
def __init__(self, repo: LeaveWishRepository) -> None:
|
||||
self._repo = repo
|
||||
|
||||
async def create(
|
||||
self,
|
||||
workspace_id: UUID,
|
||||
name: str,
|
||||
window_start_month: int,
|
||||
window_start_day: int,
|
||||
window_end_month: int,
|
||||
window_end_day: int,
|
||||
target_weeks: int,
|
||||
first_year: int,
|
||||
child_id: UUID | None = None,
|
||||
recurrence: str = "once",
|
||||
last_year: int | None = None,
|
||||
priority: int = 1,
|
||||
parent_mode: str = "either",
|
||||
parent_personal_data_uuid: UUID | None = None,
|
||||
goal_override: str | None = None,
|
||||
window_mode: str = "fixed",
|
||||
) -> LeaveWish:
|
||||
_validate(
|
||||
window_start_month=window_start_month,
|
||||
window_start_day=window_start_day,
|
||||
window_end_month=window_end_month,
|
||||
window_end_day=window_end_day,
|
||||
target_weeks=target_weeks,
|
||||
recurrence=recurrence,
|
||||
first_year=first_year,
|
||||
last_year=last_year,
|
||||
parent_mode=parent_mode,
|
||||
parent_personal_data_uuid=parent_personal_data_uuid,
|
||||
goal_override=goal_override,
|
||||
window_mode=window_mode,
|
||||
)
|
||||
now = datetime.now()
|
||||
entity = LeaveWish(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
child_id=child_id,
|
||||
name=name,
|
||||
window_start_month=window_start_month,
|
||||
window_start_day=window_start_day,
|
||||
window_end_month=window_end_month,
|
||||
window_end_day=window_end_day,
|
||||
target_weeks=target_weeks,
|
||||
recurrence=recurrence,
|
||||
first_year=first_year,
|
||||
last_year=last_year,
|
||||
priority=priority,
|
||||
parent_mode=parent_mode,
|
||||
parent_personal_data_uuid=parent_personal_data_uuid,
|
||||
goal_override=goal_override,
|
||||
window_mode=window_mode,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return await self._repo.create(entity)
|
||||
|
||||
async def get(self, leave_wish_id: UUID) -> LeaveWish:
|
||||
entity = await self._repo.get_by_id(leave_wish_id)
|
||||
if entity is None:
|
||||
raise NotFoundError(f"LeaveWish {leave_wish_id} not found")
|
||||
return entity
|
||||
|
||||
async def list_for_workspace(self, workspace_id: UUID) -> list[LeaveWish]:
|
||||
return await self._repo.get_by_workspace(workspace_id)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
leave_wish_id: UUID,
|
||||
child_id: UUID | None = None,
|
||||
name: str | None = None,
|
||||
window_start_month: int | None = None,
|
||||
window_start_day: int | None = None,
|
||||
window_end_month: int | None = None,
|
||||
window_end_day: int | None = None,
|
||||
target_weeks: int | None = None,
|
||||
recurrence: str | None = None,
|
||||
first_year: int | None = None,
|
||||
last_year: int | None = None,
|
||||
priority: int | None = None,
|
||||
parent_mode: str | None = None,
|
||||
parent_personal_data_uuid: UUID | None = None,
|
||||
goal_override: str | None = None,
|
||||
window_mode: str | None = None,
|
||||
_clear_child_id: bool = False,
|
||||
_clear_last_year: bool = False,
|
||||
_clear_parent_personal_data_uuid: bool = False,
|
||||
_clear_goal_override: bool = False,
|
||||
) -> LeaveWish:
|
||||
"""Partial update — only non-None fields are applied.
|
||||
|
||||
`_clear_*` flags exist because a nullable field's "no change" and
|
||||
"set to None" both look like `None` on the wire; the router passes
|
||||
them based on whether the field was present in the request payload.
|
||||
"""
|
||||
entity = await self.get(leave_wish_id)
|
||||
|
||||
if _clear_child_id:
|
||||
entity.child_id = None
|
||||
elif child_id is not None:
|
||||
entity.child_id = child_id
|
||||
if name is not None:
|
||||
entity.name = name
|
||||
if window_start_month is not None:
|
||||
entity.window_start_month = window_start_month
|
||||
if window_start_day is not None:
|
||||
entity.window_start_day = window_start_day
|
||||
if window_end_month is not None:
|
||||
entity.window_end_month = window_end_month
|
||||
if window_end_day is not None:
|
||||
entity.window_end_day = window_end_day
|
||||
if target_weeks is not None:
|
||||
entity.target_weeks = target_weeks
|
||||
if recurrence is not None:
|
||||
entity.recurrence = recurrence
|
||||
if first_year is not None:
|
||||
entity.first_year = first_year
|
||||
if _clear_last_year:
|
||||
entity.last_year = None
|
||||
elif last_year is not None:
|
||||
entity.last_year = last_year
|
||||
if priority is not None:
|
||||
entity.priority = priority
|
||||
if parent_mode is not None:
|
||||
entity.parent_mode = parent_mode
|
||||
if _clear_parent_personal_data_uuid:
|
||||
entity.parent_personal_data_uuid = None
|
||||
elif parent_personal_data_uuid is not None:
|
||||
entity.parent_personal_data_uuid = parent_personal_data_uuid
|
||||
if _clear_goal_override:
|
||||
entity.goal_override = None
|
||||
elif goal_override is not None:
|
||||
entity.goal_override = goal_override
|
||||
if window_mode is not None:
|
||||
entity.window_mode = window_mode
|
||||
|
||||
_validate(
|
||||
window_start_month=entity.window_start_month,
|
||||
window_start_day=entity.window_start_day,
|
||||
window_end_month=entity.window_end_month,
|
||||
window_end_day=entity.window_end_day,
|
||||
target_weeks=entity.target_weeks,
|
||||
recurrence=entity.recurrence,
|
||||
first_year=entity.first_year,
|
||||
last_year=entity.last_year,
|
||||
parent_mode=entity.parent_mode,
|
||||
parent_personal_data_uuid=entity.parent_personal_data_uuid,
|
||||
goal_override=entity.goal_override,
|
||||
window_mode=entity.window_mode,
|
||||
)
|
||||
return await self._repo.update(leave_wish_id, entity)
|
||||
|
||||
async def delete(self, leave_wish_id: UUID) -> None:
|
||||
await self.get(leave_wish_id) # raises NotFoundError if missing
|
||||
await self._repo.delete(leave_wish_id)
|
||||
|
||||
|
||||
def _validate(
|
||||
*,
|
||||
window_start_month: int,
|
||||
window_start_day: int,
|
||||
window_end_month: int,
|
||||
window_end_day: int,
|
||||
target_weeks: int,
|
||||
recurrence: str,
|
||||
first_year: int,
|
||||
last_year: int | None,
|
||||
parent_mode: str,
|
||||
parent_personal_data_uuid: UUID | None,
|
||||
goal_override: str | None,
|
||||
window_mode: str,
|
||||
) -> None:
|
||||
if recurrence not in _VALID_RECURRENCE:
|
||||
raise ValidationError(f"recurrence must be one of {_VALID_RECURRENCE}")
|
||||
if parent_mode not in _VALID_PARENT_MODE:
|
||||
raise ValidationError(f"parent_mode must be one of {_VALID_PARENT_MODE}")
|
||||
if goal_override is not None and goal_override not in _VALID_GOAL_OVERRIDE:
|
||||
raise ValidationError(f"goal_override must be one of {_VALID_GOAL_OVERRIDE} or None")
|
||||
if window_mode not in _VALID_WINDOW_MODE:
|
||||
raise ValidationError(f"window_mode must be one of {_VALID_WINDOW_MODE}")
|
||||
|
||||
_validate_month_day(window_start_month, window_start_day, label="window_start")
|
||||
_validate_month_day(window_end_month, window_end_day, label="window_end")
|
||||
|
||||
if target_weeks <= 0:
|
||||
raise ValidationError("target_weeks must be > 0")
|
||||
|
||||
if last_year is not None and last_year < first_year:
|
||||
raise ValidationError("last_year must be >= first_year")
|
||||
|
||||
if parent_mode == "specific" and parent_personal_data_uuid is None:
|
||||
raise ValidationError(
|
||||
"parent_personal_data_uuid is required when parent_mode is 'specific'"
|
||||
)
|
||||
if parent_mode != "specific" and parent_personal_data_uuid is not None:
|
||||
raise ValidationError(
|
||||
"parent_personal_data_uuid must not be set unless parent_mode is 'specific'"
|
||||
)
|
||||
|
||||
if window_mode == "flexible":
|
||||
# PLANNING_ENGINE_SPEC.md §10.1 — the bounding range's raw length
|
||||
# (before any horizon clipping) must be able to fit target_weeks;
|
||||
# "find a 6-week window inside this 2-week range" is a user-input
|
||||
# error, not a legitimate future shortfall.
|
||||
bound_days = _bound_length_days(
|
||||
window_start_month, window_start_day, window_end_month, window_end_day, first_year
|
||||
)
|
||||
needed_days = target_weeks * 7
|
||||
if bound_days < needed_days:
|
||||
raise ValidationError(
|
||||
"flexible wish bounding range "
|
||||
f"({bound_days} days) is shorter than target_weeks "
|
||||
f"({target_weeks} weeks = {needed_days} days)"
|
||||
)
|
||||
|
||||
|
||||
def _validate_month_day(month: int, day: int, *, label: str) -> None:
|
||||
if not (1 <= month <= 12):
|
||||
raise ValidationError(f"{label}_month must be between 1 and 12")
|
||||
if not (1 <= day <= 31):
|
||||
raise ValidationError(f"{label}_day must be between 1 and 31")
|
||||
try:
|
||||
# Use a leap year as the reference so Feb 29 validates; this only
|
||||
# checks the (month, day) combination is a real calendar date, not
|
||||
# that it is valid in every year it might recur in.
|
||||
date(_LEAP_REFERENCE_YEAR, month, day)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"{label} is not a valid calendar date: {month}-{day}") from exc
|
||||
|
||||
|
||||
def _bound_length_days(
|
||||
start_month: int, start_day: int, end_month: int, end_day: int, anchor_year: int
|
||||
) -> int:
|
||||
"""Span (inclusive, in days) of a window/bound anchored at `anchor_year`.
|
||||
|
||||
Mirrors `LeaveWish.window_wraps()`/`window_for_year()` without requiring a
|
||||
full entity — used at validation time (§10.1), before an entity exists in
|
||||
`create()`. `anchor_year` should be a real calendar year (not the leap
|
||||
reference year `_validate_month_day` uses) so Feb 29 in a non-leap
|
||||
`first_year` naturally raises via `date()`, consistent with
|
||||
`_validate_month_day`'s own calendar-date check.
|
||||
"""
|
||||
wraps = (end_month, end_day) < (start_month, start_day)
|
||||
start = date(anchor_year, start_month, start_day)
|
||||
end_year = anchor_year + 1 if wraps else anchor_year
|
||||
end = date(end_year, end_month, end_day)
|
||||
return (end - start).days + 1
|
||||
Reference in New Issue
Block a user