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