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:
2026-06-01 17:47:17 +02:00
parent 53d157535a
commit fbb7a04580
15 changed files with 1092 additions and 62 deletions
+252 -9
View File
@@ -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