Delete date helpers nothing calls

A hand-rolled month-arithmetic module survived the move to dateutil with no
caller left but its own tests, and two predicates alongside it were never
wired up at all. An error import was also being deferred inside a function
for a circular dependency that does not exist.
This commit is contained in:
2026-09-18 01:09:42 +02:00
parent 4fa93c4985
commit 6a79435baf
6 changed files with 15 additions and 320 deletions
+1 -3
View File
@@ -29,7 +29,7 @@ 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.workspace_repository import WorkspaceRepository
from app.utils.errors import NotFoundError
from app.utils.errors import DubbeldagError, NotFoundError
from app.utils.tip_predicates import days_left_at_age_4_projection, resolve_age4_savable_cap
@@ -188,8 +188,6 @@ class AnalyticsService:
Called from the planning service before persisting a new day.
"""
from app.utils.errors import DubbeldagError
child = await self._children.get_by_id(child_id)
if child is None:
raise NotFoundError(f"Child {child_id} not found")
-64
View File
@@ -1,64 +0,0 @@
"""Date calculation utilities for leave planning.
Pure functions with no I/O or state. Used by planning and analytics services.
"""
import datetime
def add_months(date: datetime.date, n: int) -> datetime.date:
"""Add or subtract months from a date, handling month-end edge cases.
Examples:
add_months(datetime.date(2026, 1, 31), 1) -> 2026-02-28 (or 29 in leap year)
add_months(datetime.date(2026, 3, 31), -1) -> 2026-02-28
"""
month = date.month + n
year = date.year
while month > 12:
month -= 12
year += 1
while month < 1:
month += 12
year -= 1
# Handle month-end: if original day doesn't exist in target month,
# use last day of that month
day = min(date.day, _days_in_month(year, month))
return datetime.date(year, month, day)
def _days_in_month(year: int, month: int) -> int:
"""Return number of days in the given month."""
if month == 2:
return 29 if _is_leap_year(year) else 28
if month in (4, 6, 9, 11):
return 30
return 31
def _is_leap_year(year: int) -> bool:
"""Check if a year is a leap year."""
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def age_in_months(birth_date: datetime.date, on: datetime.date) -> int:
"""Calculate exact age in months between two dates.
If on < birth_date, returns negative value (person not yet born).
"""
months = (on.year - birth_date.year) * 12 + (on.month - birth_date.month)
# Adjust if birthday hasn't occurred this month yet
if on.day < birth_date.day:
months -= 1
return months
def fourth_birthday(birth_date: datetime.date) -> datetime.date:
"""Return the date when a child turns 4 years old."""
return add_months(birth_date, 48)
-6
View File
@@ -183,12 +183,6 @@ def is_swedish_holiday(date: datetime.date) -> bool:
return date in holidays_for_year
def get_holiday_name(date: datetime.date) -> str | None:
"""Get the name of a Swedish public holiday, or None if not a holiday."""
holidays_for_year = get_swedish_holidays(date.year)
return holidays_for_year.get(date)
def is_non_working_day(date: datetime.date) -> bool:
"""True if `date` is a weekend day, a public holiday, or a de facto day
off (julafton, midsommarafton, nyårsafton, ...).
+14 -6
View File
@@ -13,7 +13,7 @@ from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from app.config import settings
from app.config import LeaveRules, settings
from app.entities.child import Child
from app.entities.personal_data import PersonalData
from app.entities.planned_leave import PlannedLeaveDay
@@ -32,6 +32,19 @@ def weekly_intensity(planned_days: list[PlannedLeaveDay], period_weeks: float) -
return len(fp_days) / period_weeks
def default_tier_pool_share(rules: LeaveRules) -> tuple[int, int]:
"""The per-parent (sjukpenning_left, lagsta_left) assumed when no FK
snapshot exists yet.
`rules.sjukpenning_days`/`lagsta_niva_days` are the WHOLE CHILD's combined
tier totals (390 + 90), split across both parents — halving them gives a
single parent's default share. Shared by `AllocationInputLoader.load` and
`PlanningService._tier_pool_left` so they can never derive it differently
(see "Bug A": they once did, 390/90 in one place vs. 195/45 in the other).
"""
return rules.sjukpenning_days // 2, rules.lagsta_niva_days // 2
def resolve_age4_savable_cap(planning_mode: str, parent_count: int) -> int:
"""Single source of truth for which age-4 savable-days ceiling applies.
@@ -100,11 +113,6 @@ def age4_pooling_loss(
return max(reachable_pooled - reachable_per_parent, 0)
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)
-204
View File
@@ -1,204 +0,0 @@
"""Tests for date utility functions."""
import datetime
from app.utils.dates import add_months, age_in_months, fourth_birthday
class TestAddMonths:
"""Test add_months function."""
def test_add_positive_months(self) -> None:
"""Adding positive months increments the month correctly."""
date = datetime.date(2026, 1, 15)
result = add_months(date, 1)
assert result == datetime.date(2026, 2, 15)
def test_add_multiple_months(self) -> None:
"""Adding multiple months increments year when needed."""
date = datetime.date(2026, 11, 15)
result = add_months(date, 2)
assert result == datetime.date(2027, 1, 15)
def test_add_12_months(self) -> None:
"""Adding exactly 12 months increments year only."""
date = datetime.date(2026, 3, 15)
result = add_months(date, 12)
assert result == datetime.date(2027, 3, 15)
def test_add_many_months(self) -> None:
"""Adding many months works correctly."""
date = datetime.date(2026, 1, 15)
result = add_months(date, 48) # 4 years
assert result == datetime.date(2030, 1, 15)
def test_subtract_months(self) -> None:
"""Subtracting months (negative n) works correctly."""
date = datetime.date(2026, 3, 15)
result = add_months(date, -1)
assert result == datetime.date(2026, 2, 15)
def test_subtract_year(self) -> None:
"""Subtracting 12 months decrements year."""
date = datetime.date(2026, 3, 15)
result = add_months(date, -12)
assert result == datetime.date(2025, 3, 15)
def test_subtract_across_year_boundary(self) -> None:
"""Subtracting months crossing year boundary works."""
date = datetime.date(2026, 1, 15)
result = add_months(date, -2)
assert result == datetime.date(2025, 11, 15)
def test_month_end_handling_jan_to_feb_leap_year(self) -> None:
"""Jan 31 + 1 month = Feb 29 in leap year."""
date = datetime.date(2024, 1, 31) # 2024 is a leap year
result = add_months(date, 1)
assert result == datetime.date(2024, 2, 29)
def test_month_end_handling_jan_to_feb_non_leap_year(self) -> None:
"""Jan 31 + 1 month = Feb 28 in non-leap year."""
date = datetime.date(2025, 1, 31) # 2025 is not a leap year
result = add_months(date, 1)
assert result == datetime.date(2025, 2, 28)
def test_month_end_handling_mar_to_feb(self) -> None:
"""Mar 31 - 1 month = Feb 28/29."""
date = datetime.date(2025, 3, 31)
result = add_months(date, -1)
assert result == datetime.date(2025, 2, 28)
def test_month_end_handling_may_to_apr(self) -> None:
"""May 31 - 1 month = Apr 30."""
date = datetime.date(2026, 5, 31)
result = add_months(date, -1)
assert result == datetime.date(2026, 4, 30)
def test_add_zero_months(self) -> None:
"""Adding 0 months returns the same date."""
date = datetime.date(2026, 6, 15)
result = add_months(date, 0)
assert result == date
def test_leap_year_edge_case(self) -> None:
"""Feb 29 in leap year + 1 year = Feb 28 in non-leap year."""
date = datetime.date(2024, 2, 29)
result = add_months(date, 12)
assert result == datetime.date(2025, 2, 28)
def test_leap_year_century_2000_is_leap(self) -> None:
"""Year 2000 is a leap year (divisible by 400)."""
date = datetime.date(2000, 1, 31)
result = add_months(date, 1)
assert result == datetime.date(2000, 2, 29)
def test_leap_year_century_1900_not_leap(self) -> None:
"""Year 1900 is not a leap year (divisible by 100 but not 400)."""
date = datetime.date(1900, 1, 31)
result = add_months(date, 1)
assert result == datetime.date(1900, 2, 28)
class TestAgeInMonths:
"""Test age_in_months function."""
def test_same_day(self) -> None:
"""Same birth and reference date = 0 months."""
birth = datetime.date(2026, 1, 15)
on = datetime.date(2026, 1, 15)
assert age_in_months(birth, on) == 0
def test_one_month_later(self) -> None:
"""One month after birth date."""
birth = datetime.date(2026, 1, 15)
on = datetime.date(2026, 2, 15)
assert age_in_months(birth, on) == 1
def test_one_month_not_complete(self) -> None:
"""Day before one-month anniversary."""
birth = datetime.date(2026, 1, 15)
on = datetime.date(2026, 2, 14)
assert age_in_months(birth, on) == 0
def test_twelve_months(self) -> None:
"""Exactly one year."""
birth = datetime.date(2026, 1, 15)
on = datetime.date(2027, 1, 15)
assert age_in_months(birth, on) == 12
def test_48_months(self) -> None:
"""Exactly 4 years (48 months)."""
birth = datetime.date(2026, 1, 15)
on = datetime.date(2030, 1, 15)
assert age_in_months(birth, on) == 48
def test_month_boundary_not_reached(self) -> None:
"""Before the day of month anniversary."""
birth = datetime.date(2026, 1, 31)
on = datetime.date(2026, 2, 28)
assert age_in_months(birth, on) == 0
def test_month_boundary_reached(self) -> None:
"""On or after the day of month anniversary."""
birth = datetime.date(2026, 1, 31)
on = datetime.date(2026, 2, 28)
assert age_in_months(birth, on) == 0
def test_month_boundary_on_31st_to_mar(self) -> None:
"""Jan 31 to Mar 31."""
birth = datetime.date(2026, 1, 31)
on = datetime.date(2026, 3, 31)
assert age_in_months(birth, on) == 2
def test_negative_age(self) -> None:
"""Reference date before birth date (not yet born)."""
birth = datetime.date(2026, 6, 15)
on = datetime.date(2026, 1, 15)
assert age_in_months(birth, on) == -5
def test_leap_year_boundary(self) -> None:
"""Born on Feb 29 (leap year)."""
birth = datetime.date(2024, 2, 29)
on = datetime.date(2025, 2, 28)
assert age_in_months(birth, on) == 11
def test_leap_year_boundary_anniversary(self) -> None:
"""Born on Feb 29; checking on Mar 1 (anniversary passed)."""
birth = datetime.date(2024, 2, 29)
on = datetime.date(2025, 3, 1)
assert age_in_months(birth, on) == 12
class TestFourthBirthday:
"""Test fourth_birthday function."""
def test_regular_date(self) -> None:
"""Fourth birthday calculation."""
birth = datetime.date(2026, 6, 15)
result = fourth_birthday(birth)
assert result == datetime.date(2030, 6, 15)
def test_leap_year_feb_29(self) -> None:
"""Born on Feb 29; fourth birthday in non-leap year."""
birth = datetime.date(2024, 2, 29)
result = fourth_birthday(birth)
assert result == datetime.date(2028, 2, 29)
def test_jan_31(self) -> None:
"""Born on Jan 31; fourth birthday."""
birth = datetime.date(2022, 1, 31)
result = fourth_birthday(birth)
assert result == datetime.date(2026, 1, 31)
def test_dec_31(self) -> None:
"""Born on Dec 31; fourth birthday crosses year."""
birth = datetime.date(2022, 12, 31)
result = fourth_birthday(birth)
assert result == datetime.date(2026, 12, 31)
def test_fourth_birthday_is_48_months_later(self) -> None:
"""Verify fourth birthday is exactly 48 months after birth."""
birth = datetime.date(2020, 5, 10)
result = fourth_birthday(birth)
assert age_in_months(birth, result) == 48
-37
View File
@@ -8,7 +8,6 @@ import pytest
from app.utils.holidays import (
UncachedYearError,
cached_year_bounds,
get_holiday_name,
get_red_days,
get_swedish_holidays,
is_klamdag,
@@ -114,33 +113,6 @@ class TestIsSwedishHoliday:
assert not is_swedish_holiday(datetime.date(2026, 6, 1))
class TestGetHolidayName:
"""Test get_holiday_name function."""
def test_christmas_name(self) -> None:
"""Get name of Christmas."""
name = get_holiday_name(datetime.date(2026, 12, 25))
assert name is not None
assert "Jul" in name
def test_non_holiday_returns_none(self) -> None:
"""Non-holiday date returns None."""
name = get_holiday_name(datetime.date(2026, 6, 8))
assert name is None
def test_new_year_name(self) -> None:
"""Get name of New Year's Day."""
name = get_holiday_name(datetime.date(2026, 1, 1))
assert name is not None
assert "Nyår" in name
def test_midsummer_name(self) -> None:
"""Get name of Midsummer."""
name = get_holiday_name(datetime.date(2026, 6, 20))
assert name is not None
assert "Midsommar" in name
# ---------------------------------------------------------------------------
# De facto days (julafton, midsommarafton, nyårsafton) — cached-year source
# ---------------------------------------------------------------------------
@@ -154,22 +126,13 @@ class TestDeFactoDaysAreNonWorking:
def test_julafton_is_non_working(self) -> None:
assert is_non_working_day(datetime.date(2027, 12, 24))
assert is_swedish_holiday(datetime.date(2027, 12, 24))
name = get_holiday_name(datetime.date(2027, 12, 24))
assert name is not None
assert "Julafton" in name
def test_nyarsafton_is_non_working(self) -> None:
assert is_non_working_day(datetime.date(2027, 12, 31))
name = get_holiday_name(datetime.date(2027, 12, 31))
assert name is not None
assert "Nyårsafton" in name
def test_midsommarafton_is_non_working(self) -> None:
# Midsommardagen 2027 is Sat 2027-06-26 -> midsommarafton is Fri 6/25.
assert is_non_working_day(datetime.date(2027, 6, 25))
name = get_holiday_name(datetime.date(2027, 6, 25))
assert name is not None
assert "Midsommarafton" in name
def test_ordinary_weekday_is_still_working(self) -> None:
assert not is_non_working_day(datetime.date(2027, 3, 2)) # a plain Tuesday