76b1e712b9
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
150 lines
5.4 KiB
Python
150 lines
5.4 KiB
Python
"""Predicate helpers for the tips engine.
|
|
|
|
These are pure functions (no I/O) that the tips service calls after loading
|
|
the workspace context. Each takes pre-fetched data and returns a bool or a
|
|
supporting value. Keeping them pure makes them trivial to unit-test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from app.config import settings
|
|
from app.entities.child import Child
|
|
from app.entities.personal_data import PersonalData
|
|
from app.entities.planned_leave import PlannedLeaveDay
|
|
from app.utils.holidays import is_non_working_day
|
|
|
|
|
|
def weekly_intensity(planned_days: list[PlannedLeaveDay], period_weeks: float) -> float:
|
|
"""Average föräldrapenning days per week over a given period.
|
|
|
|
Only sjukpenning+lagsta count; vacation days are excluded (they don't draw
|
|
from the FP pool and are not the planning knob that affects SGI).
|
|
"""
|
|
if period_weeks <= 0:
|
|
return 0.0
|
|
fp_days = [d for d in planned_days if d.day_type in ("sjukpenning", "lagsta")]
|
|
return len(fp_days) / period_weeks
|
|
|
|
|
|
def days_left_at_age_4_projection(
|
|
days_used: int,
|
|
planned_fp_before_b4: int,
|
|
total_days: int,
|
|
) -> tuple[int, int]:
|
|
"""Return (projected_left_at_b4, expiring_at_b4) without hitting the DB.
|
|
|
|
Delegates the full DB-backed version to AnalyticsService. Use this in
|
|
tests or when the numbers are already loaded.
|
|
"""
|
|
rules = settings.rules
|
|
projected_left = total_days - (days_used + planned_fp_before_b4)
|
|
expiring = max(projected_left - rules.max_savable_days_at_age_4, 0)
|
|
return projected_left, expiring
|
|
|
|
|
|
def is_child_under_age(birth_date: date, months: int) -> bool:
|
|
"""True if the child's age in months is less than `months`."""
|
|
return date.today() < birth_date + relativedelta(months=months)
|
|
|
|
|
|
def is_child_over_age(birth_date: date, years: int) -> bool:
|
|
"""True if the child has turned `years`."""
|
|
return date.today() >= birth_date + relativedelta(years=years)
|
|
|
|
|
|
def sgi_at_risk(
|
|
personal_data: PersonalData,
|
|
planned_days: list[PlannedLeaveDay],
|
|
child_birth_date: date,
|
|
period_start: date,
|
|
period_end: date,
|
|
) -> bool:
|
|
"""True if the parent risks losing SGI (< 5 FP days/week after age 1).
|
|
|
|
SGI is protected automatically in the first 12 months (§6). After that a
|
|
non-working, non-jobseeking parent must take ≥5 days/week to keep full SGI.
|
|
"""
|
|
rules = settings.rules
|
|
if not is_child_over_age(child_birth_date, 1):
|
|
return False # still in the protected first year
|
|
|
|
if personal_data.employment_status in ("employed", "jobseeking"):
|
|
return False # working/registered jobseeker is protected regardless
|
|
|
|
period_days = [
|
|
d
|
|
for d in planned_days
|
|
if d.parent_personal_data_uuid == personal_data.uuid
|
|
and period_start <= d.date <= period_end
|
|
and d.day_type in ("sjukpenning", "lagsta")
|
|
]
|
|
weeks = ((period_end - period_start).days + 1) / 7
|
|
intensity = weekly_intensity(period_days, weeks)
|
|
return intensity < rules.sgi_min_days_per_week_after_age_1
|
|
|
|
|
|
def red_day_clusters_in_range(
|
|
from_date: date, to_date: date, year: int | None = None
|
|
) -> list[tuple[date, date]]:
|
|
"""Return (start, end) pairs for clusters of ≥2 consecutive non-working days.
|
|
|
|
Uses `app.utils.holidays.is_non_working_day` — the single source of truth
|
|
for "is this day free" (calendar cache + `holidays` library fallback),
|
|
so weekends, public holidays AND de facto days (julafton, midsommarafton,
|
|
nyårsafton, ...) all count. `year` is accepted for backward compatibility
|
|
but unused: `is_non_working_day` resolves each date's own year, so a
|
|
range spanning a year boundary is handled correctly without it.
|
|
"""
|
|
del year # unused — is_non_working_day resolves per-date
|
|
|
|
clusters: list[tuple[date, date]] = []
|
|
cluster_start: date | None = None
|
|
|
|
current = from_date
|
|
while current <= to_date:
|
|
is_holiday = is_non_working_day(current)
|
|
if is_holiday:
|
|
if cluster_start is None:
|
|
cluster_start = current
|
|
else:
|
|
if cluster_start is not None and (current - cluster_start).days >= 2:
|
|
clusters.append((cluster_start, current - timedelta(days=1)))
|
|
cluster_start = None
|
|
current += timedelta(days=1)
|
|
|
|
if cluster_start is not None and (to_date - cluster_start).days >= 2:
|
|
clusters.append((cluster_start, to_date))
|
|
|
|
return clusters
|
|
|
|
|
|
def preschool_buffer_needed(child: Child, default_days_per_year: int) -> int:
|
|
"""Days that should be reserved for preschool planning-day closures.
|
|
|
|
Returns the default_days_per_year unless the child has no preschool_start_date
|
|
yet (in which case no reservation is needed).
|
|
"""
|
|
if child.preschool_start_date is None:
|
|
return 0
|
|
if date.today() < child.preschool_start_date:
|
|
return 0
|
|
return default_days_per_year
|
|
|
|
|
|
def has_vacation_near_expiry(
|
|
vacation_total: int,
|
|
vacation_saved_in: int,
|
|
threshold_remaining_days: int = 5,
|
|
) -> bool:
|
|
"""True if saved vacation days are approaching expiry (simple heuristic).
|
|
|
|
Swedish law: vacation days above 20 may be saved for up to 5 years.
|
|
This predicate flags when saved days exist — the tips service adds richer
|
|
expiry logic when it has the full vacation history per year.
|
|
"""
|
|
return vacation_saved_in > 0 and vacation_total > threshold_remaining_days
|