Link planned days to their wish, fix the age-4 cap, drop the planning goal

Three changes that together unblock treating a calendar period as one
editable object.

Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL)
so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a
day the user hand-edited, and `leave_wish.target_days` so a period drawn on a
calendar need not be a whole number of weeks. Apply-time identity still keys on
day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update
rather than a collision.

The age-4 savable ceiling was applying the per-child figure of 96 to each
parent's pool, modelling a two-parent family as able to save 192 days past the
child's fourth birthday. `resolve_age4_savable_cap` is now the single place
that decides 48 (couple with two parents) versus 96 (everyone else), and both
AnalyticsService and the allocator read it.

Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`.
The allocator's tier-3 policy collapses to one rule; `salary_type` still
branches, since that reflects what a vacation day actually costs a parent
rather than a user preference.
This commit is contained in:
2026-09-15 20:28:06 +02:00
parent 7f516f2cab
commit cc3ced905e
59 changed files with 1427 additions and 740 deletions
+11 -34
View File
@@ -44,9 +44,9 @@ from app.utils.tip_predicates import (
days_left_at_age_4_projection,
has_vacation_near_expiry,
is_child_over_age,
is_child_under_age,
preschool_buffer_needed,
red_day_clusters_in_range,
resolve_age4_savable_cap,
sgi_at_risk,
weekly_intensity,
)
@@ -63,7 +63,6 @@ class Tip:
@dataclass
class TipsContext:
workspace_id: UUID
workspace_planning_goal: str # 'max_time' | 'max_money' | 'balanced'
children: list[Child]
parents: list[PersonalData]
leave_by_parent: dict[UUID, ParentalLeaveFetch | None] # personal_data_uuid → latest
@@ -71,6 +70,10 @@ class TipsContext:
vacation_by_parent: dict[UUID, list[VacationDays]]
leave_wishes: list[LeaveWish]
today: date # injected for deterministic testing
# 'solo' | 'couple' — see `app.utils.tip_predicates.resolve_age4_savable_cap`.
# Defaulted for backward compatibility with call sites/tests that don't
# care about the age-4 cap split.
workspace_planning_mode: str = "solo"
# Type alias for a rule function.
@@ -131,6 +134,7 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
return None
child = ctx.children[0]
fourth_bday = child.birth_date + _relativedelta(years=4)
savable_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents))
for parent in ctx.parents:
leave = ctx.leave_by_parent.get(parent.uuid)
if leave is None:
@@ -143,7 +147,7 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
and d.date < fourth_bday
)
_, expiring_at_b4 = days_left_at_age_4_projection(
leave.days_used, planned_fp_before_b4, leave.total_days
leave.days_used, planned_fp_before_b4, leave.total_days, savable_cap
)
if expiring_at_b4 > 0:
return Tip(
@@ -216,6 +220,7 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
fourth_bday = child.birth_date + _relativedelta(years=4)
today = ctx.today
window_end = today + timedelta(weeks=4)
savable_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents))
for parent in ctx.parents:
parent_window_days = [
@@ -237,7 +242,7 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
and d.date < fourth_bday
)
projected_left, _ = days_left_at_age_4_projection(
leave.days_used, planned_fp_before_b4, leave.total_days
leave.days_used, planned_fp_before_b4, leave.total_days, savable_cap
)
if projected_left < 60:
return Tip(
@@ -253,29 +258,6 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
return None
async def _rule_skip_fp_first_12_months(ctx: TipsContext) -> Tip | None:
if ctx.workspace_planning_goal != "max_time":
return None
for child in ctx.children:
if is_child_under_age(child.birth_date, months=12):
for parent in ctx.parents:
fp_planned = [
d
for d in ctx.planned_days
if d.parent_personal_data_uuid == parent.uuid
and d.day_type in ("sjukpenning", "lagsta")
and d.date < child.birth_date + _relativedelta(months=12)
]
if fp_planned:
return Tip(
id="skip_fp_first_12_months",
severity="info",
message_key="tips.skip_fp_first_12_months",
data={"parent_name": parent.name},
)
return None
async def _rule_sgi_minimum_after_age_1(ctx: TipsContext) -> Tip | None:
today = ctx.today
for child in ctx.children:
@@ -297,8 +279,6 @@ async def _rule_sgi_minimum_after_age_1(ctx: TipsContext) -> Tip | None:
async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None:
if ctx.workspace_planning_goal != "max_money":
return None
today = ctx.today
look_ahead = today + timedelta(weeks=8)
for parent in ctx.parents:
@@ -329,8 +309,6 @@ async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None:
async def _rule_wrap_red_days_with_vacation(ctx: TipsContext) -> Tip | None:
if ctx.workspace_planning_goal != "max_money":
return None
today = ctx.today
look_ahead = today + timedelta(weeks=12)
clusters = red_day_clusters_in_range(today, look_ahead)
@@ -455,7 +433,6 @@ RULE_REGISTRY: list[RuleFn] = [
_rule_use_dubbeldagar_strategically,
_rule_lagsta_niva_coverage,
_rule_stretch_time_at_home,
_rule_skip_fp_first_12_months,
_rule_sgi_minimum_after_age_1,
_rule_wrap_weekend_with_vacation,
_rule_wrap_red_days_with_vacation,
@@ -513,11 +490,10 @@ class TipsService:
)
ws = await self._workspace_repo.get_by_id(workspace_id)
planning_goal = ws.planning_goal if ws else "balanced"
planning_mode = ws.planning_mode if ws else "solo"
return TipsContext(
workspace_id=workspace_id,
workspace_planning_goal=planning_goal,
children=children,
parents=parents,
leave_by_parent=leave_by_parent,
@@ -525,4 +501,5 @@ class TipsService:
vacation_by_parent=vacation_by_parent,
leave_wishes=leave_wishes,
today=today,
workspace_planning_mode=planning_mode,
)