From 6a79435baf7d5751310e736bea36a3cf6d293d82 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Fri, 18 Sep 2026 01:09:42 +0200 Subject: [PATCH] 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. --- backend/app/services/analytics_service.py | 4 +- backend/app/utils/dates.py | 64 ------- backend/app/utils/holidays.py | 6 - backend/app/utils/tip_predicates.py | 20 ++- backend/tests/unit/test_dates.py | 204 ---------------------- backend/tests/unit/test_holidays.py | 37 ---- 6 files changed, 15 insertions(+), 320 deletions(-) delete mode 100644 backend/app/utils/dates.py delete mode 100644 backend/tests/unit/test_dates.py diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index 2a80e3b..74eaa96 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -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") diff --git a/backend/app/utils/dates.py b/backend/app/utils/dates.py deleted file mode 100644 index 2895d3e..0000000 --- a/backend/app/utils/dates.py +++ /dev/null @@ -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) diff --git a/backend/app/utils/holidays.py b/backend/app/utils/holidays.py index 2f13bca..aab5b3d 100644 --- a/backend/app/utils/holidays.py +++ b/backend/app/utils/holidays.py @@ -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, ...). diff --git a/backend/app/utils/tip_predicates.py b/backend/app/utils/tip_predicates.py index 0f7c872..5e81f35 100644 --- a/backend/app/utils/tip_predicates.py +++ b/backend/app/utils/tip_predicates.py @@ -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) diff --git a/backend/tests/unit/test_dates.py b/backend/tests/unit/test_dates.py deleted file mode 100644 index ac6e958..0000000 --- a/backend/tests/unit/test_dates.py +++ /dev/null @@ -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 diff --git a/backend/tests/unit/test_holidays.py b/backend/tests/unit/test_holidays.py index 09e9f1b..c30bde5 100644 --- a/backend/tests/unit/test_holidays.py +++ b/backend/tests/unit/test_holidays.py @@ -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