"""Tips engine. Architecture: - `Tip` is a plain dataclass returned to the router. - `RULE_REGISTRY` is a list of async functions; each takes a `TipsContext` and returns a `Tip | None`. None means the rule doesn't apply right now. - `TipsService.get_tips()` runs all rules and returns the non-None results. All 12 rules from ARCHITECTURE.md §6.1 are registered; the stubs return None until implemented. This lets the junior wire rules one at a time by editing the corresponding function without touching the plumbing. To add a new rule: 1. Write an async function `_rule_(ctx: TipsContext) -> Tip | None`. 2. Append it to RULE_REGISTRY. """ from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import date, timedelta from typing import Callable, Coroutine, TypeAlias from uuid import UUID from dateutil.relativedelta import relativedelta as _relativedelta from app.config import settings from app.entities.break_period import BreakPeriod from app.entities.child import Child from app.entities.parental_leave import ParentalLeaveFetch from app.entities.personal_data import PersonalData from app.entities.planned_leave import PlannedLeaveDay from app.entities.vacation_days import VacationDays from app.repositories.break_period_repository import BreakPeriodRepository from app.repositories.child_repository import ChildRepository from app.repositories.parental_leave_repository import ParentalLeaveRepository from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.repositories.vacation_days_repository import VacationDaysRepository from app.repositories.workspace_repository import WorkspaceRepository from app.utils.tip_predicates import ( has_vacation_near_expiry, is_child_over_age, is_child_under_age, sgi_at_risk, ) @dataclass class Tip: id: str severity: str # 'info' | 'warning' | 'action' message_key: str # i18n key for frontend data: dict # context for frontend rendering (e.g. {days: 5, period: "Jul–Aug"}) @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 planned_days: list[PlannedLeaveDay] # all future planned days for workspace vacation_by_parent: dict[UUID, list[VacationDays]] break_periods: list[BreakPeriod] # Type alias for a rule function. RuleFn: TypeAlias = Callable[[TipsContext], Coroutine[None, None, Tip | None]] # --------------------------------------------------------------------------- # Rule implementations (stub → return None until fully wired) # --------------------------------------------------------------------------- async def _rule_use_vacation_before_expiry(ctx: TipsContext) -> Tip | None: for parent in ctx.parents: for vd in ctx.vacation_by_parent.get(parent.uuid, []): if has_vacation_near_expiry(vd.total_days, vd.saved_days_in): return Tip( id="use_vacation_before_expiry", severity="warning", message_key="tips.use_vacation_before_expiry", data={ "parent_name": parent.name, "saved_days": vd.saved_days_in, "year": vd.year, }, ) return None async def _rule_balance_between_parents(ctx: TipsContext) -> Tip | None: # TODO: compare days_used across parents; flag imbalance > 30 days return None async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None: # TODO: use analytics_service.get_days_left_at_age_4; fire when expiring_at_b4 > 0 return None async def _rule_use_dubbeldagar_strategically(ctx: TipsContext) -> Tip | None: # TODO: detect dates where both parents plan the same day without is_dubbeldag=True return None async def _rule_lagsta_niva_coverage(ctx: TipsContext) -> Tip | None: # TODO: suggest lägstanivå days when sjukpenning pool is low return None async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None: # TODO: flag when weekly_intensity >= 7 and days_left_at_b4 is low 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 = date.today() for child in ctx.children: if not is_child_over_age(child.birth_date, 1): continue period_end = today + timedelta(weeks=4) for parent in ctx.parents: if sgi_at_risk(parent, ctx.planned_days, child.birth_date, today, period_end): return Tip( id="sgi_minimum_after_age_1", severity="warning", message_key="tips.sgi_minimum_after_age_1", data={ "parent_name": parent.name, "min_days_per_week": settings.rules.sgi_min_days_per_week_after_age_1, }, ) return None async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None: if ctx.workspace_planning_goal != "max_money": return None # TODO: detect FP-spanned weekends without vacation bridging, for monthly-salary parents return None async def _rule_wrap_red_days_with_vacation(ctx: TipsContext) -> Tip | None: if ctx.workspace_planning_goal != "max_money": return None # TODO: use red_day_clusters_in_range(); flag FP overlap with clusters return None async def _rule_long_break_parent_swap(ctx: TipsContext) -> Tip | None: # TODO: match break_periods to a window where one parent could FP + other vacation return None async def _rule_reserve_for_preschool_planning_days(ctx: TipsContext) -> Tip | None: # TODO: check preschool_buffer_needed(); compare against current reserved plan return None # --------------------------------------------------------------------------- # Registry — order matters (earlier rules shown first) # --------------------------------------------------------------------------- RULE_REGISTRY: list[RuleFn] = [ _rule_use_vacation_before_expiry, _rule_balance_between_parents, _rule_preserve_for_age_4, _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, _rule_long_break_parent_swap, _rule_reserve_for_preschool_planning_days, ] class TipsService: def __init__( self, leave_repo: ParentalLeaveRepository, planned_repo: PlannedLeaveRepository, child_repo: ChildRepository, pd_repo: PersonalDataRepository, vacation_repo: VacationDaysRepository, break_repo: BreakPeriodRepository, workspace_repo: WorkspaceRepository, ) -> None: self._leave = leave_repo self._planned = planned_repo self._children = child_repo self._parents = pd_repo self._vacation = vacation_repo self._breaks = break_repo self._workspace_repo = workspace_repo async def get_tips(self, workspace_id: UUID) -> list[Tip]: """Load workspace context then run all rules, returning non-None tips.""" ctx = await self._build_context(workspace_id) results = await asyncio.gather(*(rule(ctx) for rule in RULE_REGISTRY)) return [tip for tip in results if tip is not None] async def _build_context(self, workspace_id: UUID) -> TipsContext: parents = await self._parents.list_by_workspace(workspace_id) children = await self._children.list_by_workspace(workspace_id) break_periods = await self._breaks.get_by_workspace(workspace_id) today = date.today() all_planned: list[PlannedLeaveDay] = [] leave_by_parent: dict[UUID, ParentalLeaveFetch | None] = {} vacation_by_parent: dict[UUID, list[VacationDays]] = {} if children: child = children[0] # MVP: single child for pd in parents: leave_by_parent[pd.uuid] = await self._leave.get_latest_by_parent(pd.uuid, child.id) future = await self._planned.list_by_workspace_child( workspace_id, child.id, from_date=today ) all_planned.extend(future) vacation_by_parent[pd.uuid] = await self._vacation.list( personal_data_uuid=pd.uuid, workspace_id=workspace_id ) ws = await self._workspace_repo.get_by_id(workspace_id) planning_goal = ws.planning_goal if ws else "balanced" return TipsContext( workspace_id=workspace_id, workspace_planning_goal=planning_goal, children=children, parents=parents, leave_by_parent=leave_by_parent, planned_days=all_planned, vacation_by_parent=vacation_by_parent, break_periods=break_periods, )