Initial commit

This commit is contained in:
2026-06-01 16:48:21 +02:00
commit 53d157535a
159 changed files with 13641 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
"""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_<id>(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,
)