Phase 1: Fix TypeScript compilation — add missing API functions and fix hook signatures
- Add 8 missing API client functions (getBreakPeriods, createBreakPeriod, deleteBreakPeriod, getTips, getParentalLeave, getPlannedDays, createPlannedDay, deletePlannedDay) - Fix useAnalytics to accept optional childId parameter - Fix ApiError strict optional properties handling - Export HeaderProps from Header component - Update hook tests to use correct data structures and optional parameters npx tsc --noEmit now passes with zero errors. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ To add a new rule:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from typing import Callable, Coroutine, TypeAlias
|
||||
@@ -40,10 +41,14 @@ 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 (
|
||||
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,
|
||||
sgi_at_risk,
|
||||
weekly_intensity,
|
||||
)
|
||||
|
||||
|
||||
@@ -94,27 +99,157 @@ async def _rule_use_vacation_before_expiry(ctx: TipsContext) -> Tip | None:
|
||||
|
||||
|
||||
async def _rule_balance_between_parents(ctx: TipsContext) -> Tip | None:
|
||||
# TODO: compare days_used across parents; flag imbalance > 30 days
|
||||
if len(ctx.parents) < 2:
|
||||
return None
|
||||
usages = [
|
||||
(p, ctx.leave_by_parent[p.uuid].days_used)
|
||||
for p in ctx.parents
|
||||
if ctx.leave_by_parent.get(p.uuid) is not None
|
||||
]
|
||||
if len(usages) < 2:
|
||||
return None
|
||||
leading, leading_used = max(usages, key=lambda x: x[1])
|
||||
lagging, lagging_used = min(usages, key=lambda x: x[1])
|
||||
gap = leading_used - lagging_used
|
||||
if gap > 30:
|
||||
return Tip(
|
||||
id="balance_between_parents",
|
||||
severity="info",
|
||||
message_key="tips.balance_between_parents",
|
||||
data={
|
||||
"leading_parent": leading.name,
|
||||
"lagging_parent": lagging.name,
|
||||
"gap_days": gap,
|
||||
},
|
||||
)
|
||||
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
|
||||
if not ctx.children:
|
||||
return None
|
||||
child = ctx.children[0]
|
||||
fourth_bday = child.birth_date + _relativedelta(years=4)
|
||||
for parent in ctx.parents:
|
||||
leave = ctx.leave_by_parent.get(parent.uuid)
|
||||
if leave is None:
|
||||
continue
|
||||
planned_fp_before_b4 = sum(
|
||||
1
|
||||
for d in ctx.planned_days
|
||||
if d.parent_personal_data_uuid == parent.uuid
|
||||
and d.day_type in ("sjukpenning", "lagsta")
|
||||
and d.date < fourth_bday
|
||||
)
|
||||
_, expiring_at_b4 = days_left_at_age_4_projection(
|
||||
leave.days_used, planned_fp_before_b4, leave.total_days
|
||||
)
|
||||
if expiring_at_b4 > 0:
|
||||
return Tip(
|
||||
id="preserve_for_age_4",
|
||||
severity="warning",
|
||||
message_key="tips.preserve_for_age_4",
|
||||
data={
|
||||
"parent_name": parent.name,
|
||||
"expiring_days": expiring_at_b4,
|
||||
"fourth_birthday": fourth_bday.isoformat(),
|
||||
},
|
||||
)
|
||||
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
|
||||
if len(ctx.parents) < 2 or not ctx.children:
|
||||
return None
|
||||
child = ctx.children[0]
|
||||
window_end = child.birth_date + _relativedelta(months=settings.rules.dubbeldagar_months_limit)
|
||||
|
||||
# Map date → set of parent UUIDs with FP planned that day (within dubbeldagar window)
|
||||
fp_by_date: dict[date, set[UUID]] = defaultdict(set)
|
||||
for d in ctx.planned_days:
|
||||
if d.day_type in ("sjukpenning", "lagsta") and d.date <= window_end:
|
||||
fp_by_date[d.date].add(d.parent_personal_data_uuid)
|
||||
|
||||
for day_date, parent_uuids in fp_by_date.items():
|
||||
if len(parent_uuids) < 2:
|
||||
continue
|
||||
already_marked = any(
|
||||
d.is_dubbeldag
|
||||
for d in ctx.planned_days
|
||||
if d.date == day_date and d.parent_personal_data_uuid in parent_uuids
|
||||
)
|
||||
if not already_marked:
|
||||
return Tip(
|
||||
id="use_dubbeldagar_strategically",
|
||||
severity="action",
|
||||
message_key="tips.use_dubbeldagar_strategically",
|
||||
data={"date": day_date.isoformat()},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _rule_lagsta_niva_coverage(ctx: TipsContext) -> Tip | None:
|
||||
# TODO: suggest lägstanivå days when sjukpenning pool is low
|
||||
for parent in ctx.parents:
|
||||
leave = ctx.leave_by_parent.get(parent.uuid)
|
||||
if leave is None:
|
||||
continue
|
||||
tier = leave.tier_data
|
||||
if tier.sjukpenning_left < 30 and tier.lagsta_niva_left > 0:
|
||||
return Tip(
|
||||
id="lagsta_niva_coverage",
|
||||
severity="info",
|
||||
message_key="tips.lagsta_niva_coverage",
|
||||
data={
|
||||
"parent_name": parent.name,
|
||||
"sjukpenning_left": tier.sjukpenning_left,
|
||||
"lagsta_left": tier.lagsta_niva_left,
|
||||
},
|
||||
)
|
||||
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
|
||||
if not ctx.children:
|
||||
return None
|
||||
child = ctx.children[0]
|
||||
fourth_bday = child.birth_date + _relativedelta(years=4)
|
||||
today = date.today()
|
||||
window_end = today + timedelta(weeks=4)
|
||||
|
||||
for parent in ctx.parents:
|
||||
parent_window_days = [
|
||||
d
|
||||
for d in ctx.planned_days
|
||||
if d.parent_personal_data_uuid == parent.uuid
|
||||
and today <= d.date <= window_end
|
||||
]
|
||||
intensity = weekly_intensity(parent_window_days, 4.0)
|
||||
if intensity < 7:
|
||||
continue
|
||||
leave = ctx.leave_by_parent.get(parent.uuid)
|
||||
if leave is None:
|
||||
continue
|
||||
planned_fp_before_b4 = sum(
|
||||
1
|
||||
for d in ctx.planned_days
|
||||
if d.parent_personal_data_uuid == parent.uuid
|
||||
and d.day_type in ("sjukpenning", "lagsta")
|
||||
and d.date < fourth_bday
|
||||
)
|
||||
projected_left, _ = days_left_at_age_4_projection(
|
||||
leave.days_used, planned_fp_before_b4, leave.total_days
|
||||
)
|
||||
if projected_left < 60:
|
||||
return Tip(
|
||||
id="stretch_time_at_home",
|
||||
severity="info",
|
||||
message_key="tips.stretch_time_at_home",
|
||||
data={
|
||||
"parent_name": parent.name,
|
||||
"weekly_intensity": round(intensity, 1),
|
||||
"projected_left": projected_left,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -164,24 +299,132 @@ 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
|
||||
# TODO: detect FP-spanned weekends without vacation bridging, for monthly-salary parents
|
||||
today = date.today()
|
||||
look_ahead = today + timedelta(weeks=8)
|
||||
for parent in ctx.parents:
|
||||
if parent.salary_type != "monthly":
|
||||
continue
|
||||
fp_dates = {
|
||||
d.date
|
||||
for d in ctx.planned_days
|
||||
if d.parent_personal_data_uuid == parent.uuid
|
||||
and d.day_type in ("sjukpenning", "lagsta")
|
||||
and today <= d.date <= look_ahead
|
||||
}
|
||||
for fp_date in sorted(fp_dates):
|
||||
if fp_date.weekday() == 4: # Friday
|
||||
monday = fp_date + timedelta(days=3)
|
||||
if monday in fp_dates:
|
||||
return Tip(
|
||||
id="wrap_weekend_with_vacation",
|
||||
severity="info",
|
||||
message_key="tips.wrap_weekend_with_vacation",
|
||||
data={
|
||||
"parent_name": parent.name,
|
||||
"friday": fp_date.isoformat(),
|
||||
"monday": monday.isoformat(),
|
||||
},
|
||||
)
|
||||
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
|
||||
today = date.today()
|
||||
look_ahead = today + timedelta(weeks=12)
|
||||
clusters = red_day_clusters_in_range(today, look_ahead)
|
||||
if not clusters:
|
||||
return None
|
||||
for parent in ctx.parents:
|
||||
if parent.salary_type != "monthly":
|
||||
continue
|
||||
fp_dates = {
|
||||
d.date
|
||||
for d in ctx.planned_days
|
||||
if d.parent_personal_data_uuid == parent.uuid
|
||||
and d.day_type in ("sjukpenning", "lagsta")
|
||||
and today <= d.date <= look_ahead
|
||||
}
|
||||
for cluster_start, cluster_end in clusters:
|
||||
day_before = cluster_start - timedelta(days=1)
|
||||
day_after = cluster_end + timedelta(days=1)
|
||||
if day_before in fp_dates or day_after in fp_dates:
|
||||
return Tip(
|
||||
id="wrap_red_days_with_vacation",
|
||||
severity="info",
|
||||
message_key="tips.wrap_red_days_with_vacation",
|
||||
data={
|
||||
"parent_name": parent.name,
|
||||
"cluster_start": cluster_start.isoformat(),
|
||||
"cluster_end": cluster_end.isoformat(),
|
||||
},
|
||||
)
|
||||
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
|
||||
if len(ctx.parents) < 2 or not ctx.break_periods:
|
||||
return None
|
||||
today = date.today()
|
||||
for bp in ctx.break_periods:
|
||||
if bp.end_date < today:
|
||||
continue
|
||||
working_days = sum(
|
||||
1
|
||||
for i in range((bp.end_date - bp.start_date).days + 1)
|
||||
if (bp.start_date + timedelta(days=i)).weekday() < 5
|
||||
)
|
||||
if working_days == 0:
|
||||
continue
|
||||
for parent in ctx.parents:
|
||||
covered = sum(
|
||||
1
|
||||
for d in ctx.planned_days
|
||||
if d.parent_personal_data_uuid == parent.uuid
|
||||
and bp.start_date <= d.date <= bp.end_date
|
||||
)
|
||||
if covered < working_days * 0.5:
|
||||
other = next(p for p in ctx.parents if p.uuid != parent.uuid)
|
||||
return Tip(
|
||||
id="long_break_parent_swap",
|
||||
severity="info",
|
||||
message_key="tips.long_break_parent_swap",
|
||||
data={
|
||||
"break_name": bp.name,
|
||||
"start_date": bp.start_date.isoformat(),
|
||||
"end_date": bp.end_date.isoformat(),
|
||||
"parent_name": parent.name,
|
||||
"other_parent_name": other.name,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _rule_reserve_for_preschool_planning_days(ctx: TipsContext) -> Tip | None:
|
||||
# TODO: check preschool_buffer_needed(); compare against current reserved plan
|
||||
if not ctx.children:
|
||||
return None
|
||||
child = ctx.children[0]
|
||||
buffer_needed = preschool_buffer_needed(
|
||||
child, settings.rules.preschool_planning_days_per_year
|
||||
)
|
||||
if buffer_needed == 0:
|
||||
return None
|
||||
for parent in ctx.parents:
|
||||
leave = ctx.leave_by_parent.get(parent.uuid)
|
||||
if leave is None:
|
||||
continue
|
||||
if leave.days_left < buffer_needed * 2:
|
||||
return Tip(
|
||||
id="reserve_for_preschool_planning_days",
|
||||
severity="info",
|
||||
message_key="tips.reserve_for_preschool_planning_days",
|
||||
data={
|
||||
"parent_name": parent.name,
|
||||
"buffer_days": buffer_needed,
|
||||
"days_left": leave.days_left,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user