diff --git a/backend/app/config.py b/backend/app/config.py index 4ed41ef..2d142ad 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -62,6 +62,12 @@ class LeaveRules(BaseModel): # allocator actually enforces (PLANNING_ENGINE_SPEC.md §9.2). min_vacation_days_per_year: int = 20 + # Semesterlagen: a vacation day saved above the floor (see + # `min_vacation_days_per_year`) may be carried forward for this many + # years before it expires. Used by `AnalyticsService`'s per-year vacation + # carry-forward bank (§ per-parent-per-year balance series). + vacation_carry_forward_years: int = 5 + @property def total_days_per_child(self) -> int: return self.sjukpenning_days + self.lagsta_niva_days diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 049eb9d..d4c20c1 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -354,8 +354,11 @@ def get_analytics_service( child_repo: ChildRepositoryDep, pd_repo: PersonalDataRepositoryDep, workspace_repo: WorkspaceRepositoryDep, + vacation_repo: VacationDaysRepositoryDep, ) -> AnalyticsService: - return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) + return AnalyticsService( + leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo + ) AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)] diff --git a/backend/app/models/analytics.py b/backend/app/models/analytics.py index aa0a517..4c787a2 100644 --- a/backend/app/models/analytics.py +++ b/backend/app/models/analytics.py @@ -5,7 +5,84 @@ from uuid import UUID from pydantic import BaseModel -from app.services.analytics_service import Age4Projection, WorkspaceOverview +from app.services.analytics_service import ( + Age4Projection, + ParentalLeaveYearBalance, + ParentYearBalance, + VacationYearBalance, + WorkspaceOverview, + YearTotals, +) + + +class VacationYearBalanceResponse(BaseModel): + year: int + earned: int + is_assumed: bool + carried_in: int + planned_use: int + left: int + carried_out: int + expiring: int + + @classmethod + def from_entity(cls, v: VacationYearBalance) -> "VacationYearBalanceResponse": + return cls( + year=v.year, + earned=v.earned, + is_assumed=v.is_assumed, + carried_in=v.carried_in, + planned_use=v.planned_use, + left=v.left, + carried_out=v.carried_out, + expiring=v.expiring, + ) + + +class ParentalLeaveYearBalanceResponse(BaseModel): + year: int + sjukpenning_used: int + lagsta_used: int + sjukpenning_left: int + lagsta_left: int + + @classmethod + def from_entity(cls, p: ParentalLeaveYearBalance) -> "ParentalLeaveYearBalanceResponse": + return cls( + year=p.year, + sjukpenning_used=p.sjukpenning_used, + lagsta_used=p.lagsta_used, + sjukpenning_left=p.sjukpenning_left, + lagsta_left=p.lagsta_left, + ) + + +class ParentYearBalanceResponse(BaseModel): + personal_data_uuid: UUID + child_id: UUID + year: int + vacation: VacationYearBalanceResponse + parental_leave: ParentalLeaveYearBalanceResponse + + @classmethod + def from_entity(cls, p: ParentYearBalance) -> "ParentYearBalanceResponse": + return cls( + personal_data_uuid=p.personal_data_uuid, + child_id=p.child_id, + year=p.year, + vacation=VacationYearBalanceResponse.from_entity(p.vacation), + parental_leave=ParentalLeaveYearBalanceResponse.from_entity(p.parental_leave), + ) + + +class YearTotalsResponse(BaseModel): + year: int + pl_left: int + vacation_left: int + + @classmethod + def from_entity(cls, y: YearTotals) -> "YearTotalsResponse": + return cls(year=y.year, pl_left=y.pl_left, vacation_left=y.vacation_left) class Age4ProjectionResponse(BaseModel): @@ -20,6 +97,7 @@ class Age4ProjectionResponse(BaseModel): savable_after_b4: int expiring_at_b4: int savable_cap_at_b4: int + year_balances: list[ParentYearBalanceResponse] @classmethod def from_entity(cls, p: Age4Projection) -> "Age4ProjectionResponse": @@ -35,6 +113,7 @@ class Age4ProjectionResponse(BaseModel): savable_after_b4=p.savable_after_b4, expiring_at_b4=p.expiring_at_b4, savable_cap_at_b4=p.savable_cap_at_b4, + year_balances=[ParentYearBalanceResponse.from_entity(yb) for yb in p.year_balances], ) @@ -45,6 +124,7 @@ class WorkspaceOverviewResponse(BaseModel): combined_days_used: int combined_days_left: int combined_expiring_at_b4: int + year_totals: list[YearTotalsResponse] @classmethod def from_entity(cls, overview: WorkspaceOverview) -> "WorkspaceOverviewResponse": @@ -57,4 +137,5 @@ class WorkspaceOverviewResponse(BaseModel): combined_days_used=overview.combined_days_used, combined_days_left=overview.combined_days_left, combined_expiring_at_b4=overview.combined_expiring_at_b4, + year_totals=[YearTotalsResponse.from_entity(y) for y in overview.year_totals], ) diff --git a/backend/app/routers/analytics.py b/backend/app/routers/analytics.py index 8c8f72f..095d910 100644 --- a/backend/app/routers/analytics.py +++ b/backend/app/routers/analytics.py @@ -9,6 +9,7 @@ from app.dependencies import ( ParentalLeaveRepositoryDep, PersonalDataRepositoryDep, PlannedLeaveRepositoryDep, + VacationDaysRepositoryDep, WorkspaceIdDep, WorkspaceRepositoryDep, WorkspaceScopeDep, @@ -26,8 +27,11 @@ def _make_service( child_repo: ChildRepositoryDep, pd_repo: PersonalDataRepositoryDep, workspace_repo: WorkspaceRepositoryDep, + vacation_repo: VacationDaysRepositoryDep, ) -> AnalyticsService: - return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) + return AnalyticsService( + leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo + ) @router.get("/{workspace_id}/overview", response_model=WorkspaceOverviewResponse) @@ -39,8 +43,11 @@ async def get_overview( child_repo: ChildRepositoryDep, pd_repo: PersonalDataRepositoryDep, workspace_repo: WorkspaceRepositoryDep, + vacation_repo: VacationDaysRepositoryDep, ) -> WorkspaceOverviewResponse: - service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) + service = _make_service( + leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo + ) try: overview = await service.get_overview(workspace_id, child_id) except NotFoundError as exc: @@ -57,8 +64,11 @@ async def get_projection( child_repo: ChildRepositoryDep, pd_repo: PersonalDataRepositoryDep, workspace_repo: WorkspaceRepositoryDep, + vacation_repo: VacationDaysRepositoryDep, ) -> WorkspaceOverviewResponse: - service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) + service = _make_service( + leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo + ) try: overview = await service.get_overview(workspace_id, child_id) except NotFoundError as exc: @@ -77,11 +87,14 @@ async def get_age4_projection( child_repo: ChildRepositoryDep, pd_repo: PersonalDataRepositoryDep, workspace_repo: WorkspaceRepositoryDep, + vacation_repo: VacationDaysRepositoryDep, scope: WorkspaceScopeDep, ) -> Age4ProjectionResponse: await scope.check_member(personal_data_uuid) await scope.check_child(child_id) - service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) + service = _make_service( + leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo + ) try: proj = await service.get_days_left_at_age_4( personal_data_uuid, child_id, workspace_id=scope.workspace_id diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index 0097048..baf762e 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -18,17 +18,22 @@ dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic. from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import date from uuid import UUID from dateutil.relativedelta import relativedelta from app.config import settings +from app.entities.child import Child +from app.entities.parental_leave import ParentalLeaveFetch +from app.entities.planned_leave import PlannedLeaveDay +from app.entities.vacation_days import VacationDays from app.repositories.child_repository import ChildRepository 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.vacation_days_repository import VacationDaysRepository from app.repositories.workspace_repository import WorkspaceRepository from app.utils.errors import DubbeldagError, NotFoundError from app.utils.tip_predicates import ( @@ -38,6 +43,85 @@ from app.utils.tip_predicates import ( ) +@dataclass +class VacationYearBalance: + """One parent's vacation (semester) balance for a single calendar year. + + Part of the per-parent, per-year balance series (`ParentYearBalance`) — + see that dataclass's docstring for the carry-forward algorithm. + """ + + year: int + earned: int # entitlement for this year: the `vacation_days` row's total_days, or the statutory default + is_assumed: bool # True when no `vacation_days` row exists for this (parent, year) + carried_in: int # days carried in from prior years, still valid + planned_use: int # committed 'vacation' planned_leave_day rows in this year (see PlanningService._vacation_days_left) + left: int # max(earned + carried_in - planned_use, 0) + carried_out: int # days saved forward into next year's carried_in + expiring: int # days that hit the 5-year (rules.vacation_carry_forward_years) cap this year — LOST if not used + + +@dataclass +class ParentalLeaveYearBalance: + """One parent's föräldrapenning (FP) balance for a single calendar year, + for one child — see `ParentYearBalance`.""" + + year: int + sjukpenning_used: int # FP sjukpenning-tier rows dated in this year + lagsta_used: int # FP lägstanivå-tier rows dated in this year + sjukpenning_left: int # running balance at this year's end + lagsta_left: int # running balance at this year's end + + +@dataclass +class ParentYearBalance: + """One (parent, child, calendar year) slice of the balance series shown + on the analytics overview — vacation and FP side by side. + + **Vacation carry-forward algorithm** (Semesterlagen): a parent must take + at least `PersonalData.min_vacation_days_per_year` (the "floor") each + year; only days earned *above* the floor may be saved, and each saved day + expires `LeaveRules.vacation_carry_forward_years` (5) years after the + year it was earned. Modelled as a FIFO bank of `(year_earned, count)` + entries: + + - Each year, `planned_use` is drawn from the bank oldest-entry-first, + then from the year's own entitlement (which is never itself banked). + - At year end, `carried_out = min(left, max(earned - floor, 0) + + old_carry_forward)` joins the bank as a new entry dated this year — + `old_carry_forward` (the surviving pre-existing bank balance) keeps its + original earn-year for its own future expiry, it is never re-dated. + - A bank entry in its final valid year (earned exactly + `vacation_carry_forward_years` years ago) that still has an unconsumed + balance drops out instead of carrying forward, and is reported as + `expiring` for that year. + + `saved_days_in` on a `vacation_days` row is normally only used to *seed* + the bank for the first year of the series (year-earned = year − 1, since + we have no earlier history to derive it from). For any LATER year in the + series, a non-zero `saved_days_in` on that year's row is instead treated + as a Settings-UI override: it replaces the computed `carried_in` (and + resets the bank to a single entry dated year − 1) rather than being + added to it — the user is telling us the true figure directly. A zero + (or absent) `saved_days_in` on a later year never overrides anything. + """ + + personal_data_uuid: UUID + child_id: UUID + year: int + vacation: VacationYearBalance + parental_leave: ParentalLeaveYearBalance + + +@dataclass +class YearTotals: + """Combined (all parents) balance for one calendar year, on the overview.""" + + year: int + pl_left: int + vacation_left: int + + @dataclass class Age4Projection: """Per-parent projection of days remaining at the child's 4th birthday.""" @@ -57,6 +141,9 @@ class Age4Projection: # `resolve_age4_savable_cap`. Surfaced so the frontend can render "X kvar # / Y sparbara" without re-deriving the rule itself. savable_cap_at_b4: int + # Per-calendar-year vacation + FP balance series, birth year through the + # year of the 4th birthday inclusive. See `ParentYearBalance`. + year_balances: list[ParentYearBalance] = field(default_factory=list) @dataclass @@ -67,6 +154,8 @@ class WorkspaceOverview: combined_days_used: int combined_days_left: int combined_expiring_at_b4: int + # Combined (all parents) per-year totals — see `YearTotals`. + year_totals: list[YearTotals] = field(default_factory=list) class AnalyticsService: @@ -77,12 +166,14 @@ class AnalyticsService: child_repo: ChildRepository, personal_data_repo: PersonalDataRepository, workspace_repo: WorkspaceRepository, + vacation_days_repo: VacationDaysRepository, ) -> None: self._leave = parental_leave_repo self._planned = planned_leave_repo self._children = child_repo self._parents = personal_data_repo self._workspace_repo = workspace_repo + self._vacation = vacation_days_repo async def get_parent_count(self, workspace_id: UUID) -> int: """Number of (non-deleted) parents on a workspace. @@ -134,6 +225,7 @@ class AnalyticsService: b4 = child.birth_date + relativedelta(years=4) latest = await self._leave.get_latest_by_parent(personal_data_uuid, child_id) + default_share: tuple[int, int] | None = None if latest is None: # No FK data yet — assume full entitlement (0 used) for this # parent's share, via the same shared helper `AllocationInputLoader. @@ -144,6 +236,7 @@ class AnalyticsService: parent_count = await self.get_parent_count(child.workspace_id) sjuk_share, lagsta_share = default_tier_pool_share(rules, parent_count) total_days = sjuk_share + lagsta_share + default_share = (sjuk_share, lagsta_share) else: days_used = latest.days_used total_days = latest.total_days @@ -180,6 +273,10 @@ class AnalyticsService: ) savable = min(projected_left, savable_cap) + year_balances = await self._compute_year_balances( + personal_data_uuid, child, b4, savable_cap, latest, default_share + ) + return Age4Projection( personal_data_uuid=personal_data_uuid, child_id=child_id, @@ -192,8 +289,228 @@ class AnalyticsService: savable_after_b4=savable, expiring_at_b4=expiring, savable_cap_at_b4=savable_cap, + year_balances=year_balances, ) + async def _compute_year_balances( + self, + personal_data_uuid: UUID, + child: Child, + fourth_birthday: date, + savable_cap: int, + latest_fetch: ParentalLeaveFetch | None, + default_share: tuple[int, int] | None, + ) -> list[ParentYearBalance]: + """Assemble the per-calendar-year vacation + FP balance series for + one (parent, child) pair — see `ParentYearBalance`'s docstring for + the vacation carry-forward algorithm. + + `years` runs from the child's birth year through the year of the + 4th birthday inclusive. + """ + years = list(range(child.birth_date.year, fourth_birthday.year + 1)) + + personal_data = await self._parents.get_by_id(personal_data_uuid) + floor = ( + personal_data.min_vacation_days_per_year + if personal_data is not None + else settings.rules.min_vacation_days_per_year + ) + + vacation_rows = await self._vacation.list(personal_data_uuid=personal_data_uuid) + vacation_by_year = {v.year: v for v in vacation_rows} + + # Vacation is budgeted per (parent, calendar year), not per child — + # see `PlanningService._vacation_days_left`'s docstring. `planned_use` + # below deliberately counts every persisted 'vacation' row for this + # parent across ALL of their children in that year, matching that + # method exactly, so this row's `left` never disagrees with the Plan + # tab's own vacation-balance check. + all_parent_days = await self._planned.list(parent_personal_data_uuid=personal_data_uuid) + vacation_use_by_year: dict[int, int] = {} + for d in all_parent_days: + if d.day_type == "vacation": + vacation_use_by_year[d.date.year] = vacation_use_by_year.get(d.date.year, 0) + 1 + + vacation_balances = self._vacation_year_series( + years, vacation_by_year, vacation_use_by_year, floor + ) + + fp_rows = [ + d + for d in all_parent_days + if d.child_id == child.id and d.day_type in ("sjukpenning", "lagsta") + ] + pl_balances = self._pl_year_series( + years, + fp_rows, + child.birth_date, + fourth_birthday, + savable_cap, + latest_fetch, + default_share, + ) + + return [ + ParentYearBalance( + personal_data_uuid=personal_data_uuid, + child_id=child.id, + year=year, + vacation=vacation_balances[i], + parental_leave=pl_balances[i], + ) + for i, year in enumerate(years) + ] + + @staticmethod + def _vacation_year_series( + years: list[int], + vacation_by_year: dict[int, VacationDays], + use_by_year: dict[int, int], + floor: int, + ) -> list[VacationYearBalance]: + """FIFO carry-forward bank simulation — see `ParentYearBalance`'s + docstring for the full algorithm this implements.""" + carry_years = settings.rules.vacation_carry_forward_years + default_earned = settings.rules.statutory_vacation_days_per_year + # (year_earned, remaining_count) entries, oldest first. + bank: list[list[int]] = [] + result: list[VacationYearBalance] = [] + + for i, year in enumerate(years): + row = vacation_by_year.get(year) + earned = row.total_days if row is not None else default_earned + is_assumed = row is None + + if i == 0: + # Seed the bank for the first year in the series — there is + # no earlier history to derive a carry-in from ourselves, so + # this is purely the user-entered figure (Settings UI), + # attributed to year - 1 for its own future expiry. + seed = row.saved_days_in if row is not None else 0 + bank = [[year - 1, seed]] if seed > 0 else [] + carried_in = seed + elif row is not None and row.saved_days_in != 0: + # Later-year override: a non-zero saved_days_in on THIS + # year's row supersedes the computed carry-in — see + # `ParentYearBalance`'s docstring. + bank = [[year - 1, row.saved_days_in]] if row.saved_days_in > 0 else [] + carried_in = row.saved_days_in + else: + carried_in = sum(count for _, count in bank) + + planned_use = use_by_year.get(year, 0) + left = max(earned + carried_in - planned_use, 0) + + # FIFO consumption: oldest bank entries first, then this year's + # own entitlement (never itself banked). + remaining_to_consume = planned_use + for entry in bank: + if remaining_to_consume <= 0: + break + take = min(entry[1], remaining_to_consume) + entry[1] -= take + remaining_to_consume -= take + bank = [e for e in bank if e[1] > 0] + + # Entries still valid to carry into next year vs. ones in their + # final valid year (expire now if any balance survived). + old_carry_forward = sum(c for ye, c in bank if ye >= year - (carry_years - 1)) + expiring = sum(c for ye, c in bank if ye == year - carry_years) + bank = [e for e in bank if e[0] >= year - (carry_years - 1)] + + current_year_saveable = max(earned - floor, 0) + carried_out = min(left, current_year_saveable + old_carry_forward) + new_entry_amount = max(carried_out - old_carry_forward, 0) + if new_entry_amount > 0: + bank.append([year, new_entry_amount]) + + result.append( + VacationYearBalance( + year=year, + earned=earned, + is_assumed=is_assumed, + carried_in=carried_in, + planned_use=planned_use, + left=left, + carried_out=carried_out, + expiring=expiring, + ) + ) + + return result + + @staticmethod + def _pl_year_series( + years: list[int], + fp_rows: list[PlannedLeaveDay], + birth_date: date, + fourth_birthday: date, + savable_cap: int, + latest_fetch: ParentalLeaveFetch | None, + default_share: tuple[int, int] | None, + ) -> list[ParentalLeaveYearBalance]: + """Running FP (sjukpenning/lagsta) balance at each year's end. + + Starts from the latest snapshot's tier lefts (or, with no snapshot, + this parent's default share — see `default_tier_pool_share`), then + subtracts planned FP rows dated after the snapshot (or from the + child's birth when there is none) cumulatively, year by year. The + age-4 clamp (`resolve_age4_savable_cap`) is applied once, in the year + of the 4th birthday, cutting lagsta first — mirrors `allocation_ + service._Ledger._maybe_clamp_age4` exactly. + """ + if latest_fetch is not None: + sjuk_left = latest_fetch.tier_data.sjukpenning_left + lagsta_left = latest_fetch.tier_data.lagsta_niva_left + cutoff = latest_fetch.fetched_at.date() + has_snapshot = True + else: + assert default_share is not None + sjuk_left, lagsta_left = default_share + cutoff = birth_date + has_snapshot = False + + used_by_year: dict[int, dict[str, int]] = {} + deduct_by_year: dict[int, dict[str, int]] = {} + for d in fp_rows: + y = d.date.year + bucket = used_by_year.setdefault(y, {"sjukpenning": 0, "lagsta": 0}) + bucket[d.day_type] += 1 + is_post_snapshot = d.date > cutoff if has_snapshot else d.date >= cutoff + if is_post_snapshot: + deduct = deduct_by_year.setdefault(y, {"sjukpenning": 0, "lagsta": 0}) + deduct[d.day_type] += 1 + + result: list[ParentalLeaveYearBalance] = [] + for year in years: + used = used_by_year.get(year, {"sjukpenning": 0, "lagsta": 0}) + deduct = deduct_by_year.get(year, {"sjukpenning": 0, "lagsta": 0}) + sjuk_left = max(sjuk_left - deduct["sjukpenning"], 0) + lagsta_left = max(lagsta_left - deduct["lagsta"], 0) + + if year == fourth_birthday.year: + remaining = sjuk_left + lagsta_left + capped = min(remaining, savable_cap) + excess = remaining - capped + if excess > 0: + cut_lagsta = min(excess, lagsta_left) + lagsta_left -= cut_lagsta + excess -= cut_lagsta + sjuk_left -= excess + + result.append( + ParentalLeaveYearBalance( + year=year, + sjukpenning_used=used["sjukpenning"], + lagsta_used=used["lagsta"], + sjukpenning_left=sjuk_left, + lagsta_left=lagsta_left, + ) + ) + + return result + async def get_overview(self, workspace_id: UUID, child_id: UUID) -> WorkspaceOverview: """Compute projections for all parents in a workspace for one child.""" members = await self._parents.list_by_workspace(workspace_id) @@ -209,6 +526,18 @@ class AnalyticsService: combined_left = sum(p.projected_left_at_b4 for p in projections) combined_expiring = sum(p.expiring_at_b4 for p in projections) + # Combined (all parents) per-year totals — see `YearTotals`. + totals_by_year: dict[int, list[int]] = {} # year -> [pl_left, vacation_left] + for proj in projections: + for yb in proj.year_balances: + acc = totals_by_year.setdefault(yb.year, [0, 0]) + acc[0] += yb.parental_leave.sjukpenning_left + yb.parental_leave.lagsta_left + acc[1] += yb.vacation.left + year_totals = [ + YearTotals(year=year, pl_left=acc[0], vacation_left=acc[1]) + for year, acc in sorted(totals_by_year.items()) + ] + return WorkspaceOverview( workspace_id=workspace_id, child_id=child_id, @@ -216,6 +545,7 @@ class AnalyticsService: combined_days_used=combined_used, combined_days_left=combined_left, combined_expiring_at_b4=combined_expiring, + year_totals=year_totals, ) async def validate_dubbeldagar(self, child_id: UUID, new_date: date) -> None: diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py index 06fcc54..3f4b29b 100644 --- a/backend/scripts/seed_demo.py +++ b/backend/scripts/seed_demo.py @@ -118,7 +118,9 @@ def _build_services(conn: aiosqlite.Connection) -> DemoServices: parental_leave = SQLiteParentalLeaveRepository(conn) planned_leave = SQLitePlannedLeaveRepository(conn) - analytics = AnalyticsService(parental_leave, planned_leave, children, personal_data, workspaces) + analytics = AnalyticsService( + parental_leave, planned_leave, children, personal_data, workspaces, vacation_repo + ) return DemoServices( auth=AuthService(users, personal_data, workspaces), diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ff98edc..2996f2c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -337,9 +337,15 @@ async def analytics_service( child_repo: SQLiteChildRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, ) -> AnalyticsService: return AnalyticsService( - parental_leave_repo, planned_leave_repo, child_repo, personal_data_repo, workspace_repo + parental_leave_repo, + planned_leave_repo, + child_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) diff --git a/backend/tests/unit/test_analytics_router.py b/backend/tests/unit/test_analytics_router.py new file mode 100644 index 0000000..b065ecb --- /dev/null +++ b/backend/tests/unit/test_analytics_router.py @@ -0,0 +1,94 @@ +"""API-level tests for `GET /api/analytics/{workspace_id}/overview` and the + +per-parent age4 endpoint — specifically that the new per-year balance series +(`year_balances`/`year_totals`) survives the wire boundary (§4.2's +service-computed-but-dropped-on-the-wire gotcha), matching the +`test_calendar_router.py` pattern. +""" + +from datetime import date, datetime +from uuid import uuid4 + +from httpx import AsyncClient + +from app.entities.child import Child +from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository +from tests.conftest import SeededIds + + +class TestGetOverviewYearBalances: + async def test_wire_shape_includes_year_balances_and_year_totals( + self, + authed_client: AsyncClient, + child_repo: SQLiteChildRepository, + seeded_ids: SeededIds, + ) -> None: + ws = seeded_ids.workspace_id + pd_uuid = seeded_ids.personal_data_uuid + birth = date(2025, 1, 1) + child = await child_repo.create( + Child( + id=uuid4(), + workspace_id=ws, + name="Kid", + birth_date=birth, + created_at=datetime.now(), + ) + ) + + response = await authed_client.get( + f"/api/analytics/{ws}/overview", params={"child_id": str(child.id)} + ) + + assert response.status_code == 200 + body = response.json() + + assert "year_totals" in body + assert [yt["year"] for yt in body["year_totals"]] == [2025, 2026, 2027, 2028, 2029] + for yt in body["year_totals"]: + assert set(yt) == {"year", "pl_left", "vacation_left"} + + assert len(body["parent_projections"]) == 1 + projection = body["parent_projections"][0] + assert projection["personal_data_uuid"] == str(pd_uuid) + assert "year_balances" in projection + assert [yb["year"] for yb in projection["year_balances"]] == [ + 2025, + 2026, + 2027, + 2028, + 2029, + ] + + birth_year = projection["year_balances"][0] + assert birth_year["personal_data_uuid"] == str(pd_uuid) + assert birth_year["child_id"] == str(child.id) + + vacation = birth_year["vacation"] + assert set(vacation) == { + "year", + "earned", + "is_assumed", + "carried_in", + "planned_use", + "left", + "carried_out", + "expiring", + } + assert vacation["earned"] == 25 # statutory default — no vacation_days row entered + assert vacation["is_assumed"] is True + assert vacation["carried_in"] == 0 + assert vacation["planned_use"] == 0 + assert vacation["left"] == 25 + + parental_leave = birth_year["parental_leave"] + assert set(parental_leave) == { + "year", + "sjukpenning_used", + "lagsta_used", + "sjukpenning_left", + "lagsta_left", + } + # `seeded_ids`'s workspace has exactly one member -> full pool. + assert parental_leave["sjukpenning_left"] == 390 + assert parental_leave["lagsta_left"] == 90 diff --git a/backend/tests/unit/test_services/test_analytics_service.py b/backend/tests/unit/test_services/test_analytics_service.py index 234c487..977649a 100644 --- a/backend/tests/unit/test_services/test_analytics_service.py +++ b/backend/tests/unit/test_services/test_analytics_service.py @@ -12,6 +12,7 @@ from dateutil.relativedelta import relativedelta from app.entities.child import Child from app.entities.parental_leave import ParentalLeaveFetch, TierData +from app.entities.planned_leave import PlannedLeaveDay from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository @@ -71,8 +72,11 @@ async def _make_service( planned_repo: SQLitePlannedLeaveRepository, pd_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, ) -> AnalyticsService: - return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) + return AnalyticsService( + leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo + ) class TestAge4Projection: @@ -83,6 +87,7 @@ class TestAge4Projection: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -97,7 +102,12 @@ class TestAge4Projection: await parental_leave_repo.create(fetch) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) proj = await service.get_days_left_at_age_4(pd_uuid, child.id) @@ -115,6 +125,7 @@ class TestAge4Projection: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -125,7 +136,12 @@ class TestAge4Projection: # No FK data entered service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) proj = await service.get_days_left_at_age_4(pd_uuid, child.id) @@ -145,6 +161,7 @@ class TestAge4Projection: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -155,7 +172,12 @@ class TestAge4Projection: await child_repo.create(child) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) proj = await service.get_days_left_at_age_4(pd_uuid, child.id) @@ -169,6 +191,7 @@ class TestAge4Projection: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -181,7 +204,12 @@ class TestAge4Projection: await parental_leave_repo.create(fetch) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) proj = await service.get_days_left_at_age_4(pd_uuid, child.id) @@ -196,11 +224,17 @@ class TestAge4Projection: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, ) -> None: from app.utils.errors import NotFoundError service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) with pytest.raises(NotFoundError): await service.get_days_left_at_age_4(uuid4(), uuid4()) @@ -214,11 +248,17 @@ class TestValidateDubbeldagar: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, ) -> None: from app.utils.errors import NotFoundError service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) with pytest.raises(NotFoundError): await service.validate_dubbeldagar(uuid4(), date.today()) @@ -230,6 +270,7 @@ class TestValidateDubbeldagar: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: from app.utils.errors import DubbeldagError @@ -240,7 +281,12 @@ class TestValidateDubbeldagar: await child_repo.create(child) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) # Date beyond the 15-month window beyond_window = birth + relativedelta(months=16) @@ -254,6 +300,7 @@ class TestValidateDubbeldagar: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: from app.config import settings @@ -282,7 +329,12 @@ class TestValidateDubbeldagar: ) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) within_window = birth + relativedelta(months=1) with pytest.raises(DubbeldagError): @@ -297,6 +349,7 @@ class TestGetOverview: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -310,7 +363,12 @@ class TestGetOverview: await parental_leave_repo.create(fetch) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) overview = await service.get_overview(ws, child.id) @@ -327,11 +385,17 @@ class TestGetOverview: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, ) -> None: from app.utils.errors import NotFoundError service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) with pytest.raises(NotFoundError): await service.get_overview(uuid4(), uuid4()) @@ -353,6 +417,7 @@ class TestCoupleWorkspacePerParentCap: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, ) -> None: from app.entities.personal_data import PersonalData from app.entities.workspace import Workspace @@ -378,7 +443,12 @@ class TestCoupleWorkspacePerParentCap: await parental_leave_repo.create(fetch_b) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) proj_a = await service.get_days_left_at_age_4(parent_a.uuid, child.id) @@ -402,6 +472,7 @@ class TestCoupleWorkspacePerParentCap: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, ) -> None: """Safety belt: a workspace with only one member so far (partner not @@ -423,7 +494,12 @@ class TestCoupleWorkspacePerParentCap: await parental_leave_repo.create(fetch) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) proj = await service.get_days_left_at_age_4(parent.uuid, child.id) assert proj.savable_cap_at_b4 == 96 @@ -446,6 +522,7 @@ class TestSingleParentWorkspaceCap: planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -457,7 +534,12 @@ class TestSingleParentWorkspaceCap: await parental_leave_repo.create(fetch) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) proj = await service.get_days_left_at_age_4(pd_uuid, child.id) assert proj.savable_cap_at_b4 == 96 @@ -515,7 +597,12 @@ class TestAgreesWithPlanProposalForTheSameCommittedState: # -- Dashboard side -- analytics = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, ) dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id) @@ -545,3 +632,467 @@ class TestAgreesWithPlanProposalForTheSameCommittedState: # fallback (240 vs. 480) fails loudly rather than merely disagreeing. assert dashboard_proj.projected_left_at_b4 == 480 assert dashboard_proj.expiring_at_b4 == 384 + + async def test_vacation_left_matches_plan_proposal_in_the_birth_year( + self, + workspace_repo: SQLiteWorkspaceRepository, + child_repo: SQLiteChildRepository, + personal_data_repo: SQLitePersonalDataRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + ) -> None: + """The birth year is the FIRST year of `year_balances`' series, so its + + `carried_in` is read straight off the `vacation_days` row's + `saved_days_in` (no multi-year FIFO simulation has happened yet) — + exactly what `AllocationInputLoader.load`'s `VacationBudget` also + does. The two surfaces must therefore agree for that one year. + (A LATER year's `left` is not asserted here: analytics simulates a + FIFO carry-forward bank across the whole series, while the plan + proposal reads whatever `saved_days_in` the user entered for that + specific year directly — they only need to agree where analytics + hasn't yet layered its own simulation on top, i.e. the first year.) + """ + from app.entities.personal_data import PersonalData + from app.entities.vacation_days import VacationDays + from app.entities.workspace import Workspace + from app.services.allocation_service import AllocationInputLoader, Allocator + + now = datetime.now() + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + parent = await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=ws.id, + name="Sam", + salary_type="monthly", + employment_status="employed", + created_at=now, + ) + ) + birth = date.today() + relativedelta(years=2) + child = _make_child(ws.id, birth) + await child_repo.create(child) + await vacation_repo.create( + VacationDays( + id=uuid4(), + personal_data_uuid=parent.uuid, + workspace_id=ws.id, + year=birth.year, + total_days=25, + saved_days_in=4, + ) + ) + + analytics = await _make_service( + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, + ) + dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id) + birth_year_balance = next( + yb for yb in dashboard_proj.year_balances if yb.year == birth.year + ) + + loader = AllocationInputLoader( + workspace_repo=workspace_repo, + child_repo=child_repo, + personal_data_repo=personal_data_repo, + parental_leave_repo=parental_leave_repo, + vacation_repo=vacation_repo, + planned_leave_repo=planned_leave_repo, + leave_wish_repo=leave_wish_repo, + ) + alloc_input = await loader.load(ws.id, today=date.today()) + proposal = Allocator().propose(alloc_input) + plan_vacation = proposal.balances_after.vacation_after[(parent.uuid, birth.year)] + + assert birth_year_balance.vacation.left == plan_vacation.available + assert birth_year_balance.vacation.left == 29 # 25 + 4 saved_days_in + + +class TestVacationYearBalanceSeries: + """Pure unit tests against `AnalyticsService._vacation_year_series` — the + + FIFO carry-forward bank simulation documented on `ParentYearBalance`. + """ + + def test_no_rows_assumes_statutory_default_and_floor(self) -> None: + years = [2025, 2026, 2027] + result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20) + + first = result[0] + assert first.earned == 25 # rules.statutory_vacation_days_per_year default + assert first.is_assumed is True + assert first.carried_in == 0 + assert first.left == 25 + assert first.carried_out == 5 # max(25 - floor(20), 0) + assert first.expiring == 0 + + def test_carry_chain_accumulates_across_years(self) -> None: + years = [2025, 2026, 2027] + result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20) + + # Unused years keep saving 5/year (25 earned - 20 floor) and the + # prior years' saved days carry forward untouched. + assert [r.carried_in for r in result] == [0, 5, 10] + assert [r.carried_out for r in result] == [5, 10, 15] + assert [r.left for r in result] == [25, 30, 35] + + def test_saved_days_expire_after_five_years(self) -> None: + from app.config import settings + + carry_years = settings.rules.vacation_carry_forward_years + # Exactly carry_years + 1 years: only 2020's own batch can possibly + # have aged out by the last year in this series. + years = list(range(2020, 2020 + carry_years + 1)) + result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20) + by_year = {r.year: r for r in result} + + # 2020's 5 saved days must not still be sitting in `carried_in` + # `carry_years` years later, and must have been reported as + # `expiring` exactly once, in the year they lapsed. + expiring_years = [y for y, r in by_year.items() if r.expiring > 0] + assert expiring_years == [2020 + carry_years] + assert by_year[2020 + carry_years].expiring == 5 + + def test_continuous_saving_expires_the_oldest_batch_every_year_thereafter(self) -> None: + """Once the bank has been running >= carry_years, an unbroken + + surplus (earned always above floor, nothing spent) ages out exactly + one batch per year from then on — steady state, not a one-off event. + """ + from app.config import settings + + carry_years = settings.rules.vacation_carry_forward_years + years = list(range(2020, 2020 + carry_years + 3)) + result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20) + by_year = {r.year: r for r in result} + + for year in years[carry_years:]: + assert by_year[year].expiring == 5 + # Carried-in plateaus at exactly carry_years batches of 5. + assert by_year[years[-1]].carried_in == 5 * carry_years + + def test_saved_days_in_override_on_later_year(self) -> None: + from app.entities.vacation_days import VacationDays + + years = [2025, 2026, 2027] + # No row for 2025 (the seed year); a later year's row overrides the + # computed carry-in per `ParentYearBalance`'s docstring. + rows = { + 2026: VacationDays( + id=uuid4(), + personal_data_uuid=uuid4(), + workspace_id=uuid4(), + year=2026, + total_days=25, + saved_days_in=12, + ), + } + result = AnalyticsService._vacation_year_series(years, rows, {}, floor=20) + by_year = {r.year: r for r in result} + + assert by_year[2025].carried_in == 0 # nothing entered/simulated yet + assert by_year[2026].carried_in == 12 # override wins over computed (5) + assert by_year[2026].is_assumed is False + # 2027 continues the FIFO simulation from the overridden figure. + assert by_year[2027].carried_in == 17 # 12 (override) + 5 (2026's own saveable) + + def test_zero_saved_days_in_on_later_year_does_not_override(self) -> None: + from app.entities.vacation_days import VacationDays + + years = [2025, 2026] + rows = { + 2026: VacationDays( + id=uuid4(), + personal_data_uuid=uuid4(), + workspace_id=uuid4(), + year=2026, + total_days=25, + saved_days_in=0, + ), + } + result = AnalyticsService._vacation_year_series(years, rows, {}, floor=20) + by_year = {r.year: r for r in result} + + # 2025 saved 5 (25 - floor 20) with no usage; a zero saved_days_in on + # 2026's row must not wipe that computed carry-in out. + assert by_year[2026].carried_in == 5 + + +class TestParentalLeaveYearBalanceSeries: + """Pure unit tests against `AnalyticsService._pl_year_series`.""" + + def test_no_snapshot_deducts_from_birth_using_default_share(self) -> None: + birth = date(2025, 1, 1) + b4 = date(2029, 1, 1) + years = [2025, 2026, 2027, 2028, 2029] + pid, cid = uuid4(), uuid4() + rows = [ + PlannedLeaveDay( + id=uuid4(), + workspace_id=uuid4(), + child_id=cid, + parent_personal_data_uuid=pid, + date=date(2026, 6, 1), + day_type="sjukpenning", + ) + ] + result = AnalyticsService._pl_year_series( + years, rows, birth, b4, savable_cap=96, latest_fetch=None, default_share=(390, 90) + ) + by_year = {r.year: r for r in result} + + assert by_year[2025].sjukpenning_left == 390 # nothing planned yet + assert by_year[2026].sjukpenning_used == 1 + assert by_year[2026].sjukpenning_left == 389 # deducted from birth (no snapshot) + + def test_snapshot_mid_series_only_deducts_post_snapshot_rows(self) -> None: + from app.entities.parental_leave import ParentalLeaveFetch, TierData + + birth = date(2025, 1, 1) + b4 = date(2029, 1, 1) + years = [2025, 2026, 2027, 2028, 2029] + pid, cid = uuid4(), uuid4() + fetch = ParentalLeaveFetch( + id=uuid4(), + personal_data_uuid=pid, + workspace_id=uuid4(), + child_id=cid, + source="manual", + fetched_at=datetime(2026, 6, 1), + total_days=480, + days_used=10, + days_left=470, + reserved_days_total=0, + reserved_days_left=0, + transferable_days_left=0, + dubbeldagar_left=60, + tier_data=TierData( + sjukpenning_total=390, + sjukpenning_used=10, + sjukpenning_left=380, + lagsta_niva_total=90, + lagsta_niva_used=0, + lagsta_niva_left=90, + ), + ) + rows = [ + # Before the snapshot — already reflected in `sjukpenning_left` + # above, must NOT be deducted again. + PlannedLeaveDay( + id=uuid4(), + workspace_id=uuid4(), + child_id=cid, + parent_personal_data_uuid=pid, + date=date(2026, 1, 1), + day_type="sjukpenning", + ), + # After the snapshot — must be deducted. + PlannedLeaveDay( + id=uuid4(), + workspace_id=uuid4(), + child_id=cid, + parent_personal_data_uuid=pid, + date=date(2026, 7, 1), + day_type="sjukpenning", + ), + ] + result = AnalyticsService._pl_year_series( + years, rows, birth, b4, savable_cap=96, latest_fetch=fetch, default_share=None + ) + by_year = {r.year: r for r in result} + + assert by_year[2026].sjukpenning_used == 2 # both rows counted for display + assert by_year[2026].sjukpenning_left == 379 # only the post-snapshot row deducted + assert by_year[2027].sjukpenning_left == 379 # unchanged, carries forward + + def test_age4_clamp_cuts_lagsta_first_in_b4_year(self) -> None: + from app.entities.parental_leave import ParentalLeaveFetch, TierData + + birth = date(2025, 1, 1) + b4 = date(2029, 1, 1) + years = [2025, 2026, 2027, 2028, 2029] + pid, cid = uuid4(), uuid4() + fetch = ParentalLeaveFetch( + id=uuid4(), + personal_data_uuid=pid, + workspace_id=uuid4(), + child_id=cid, + source="manual", + fetched_at=datetime(2025, 1, 1), + total_days=480, + days_used=0, + days_left=480, + reserved_days_total=0, + reserved_days_left=0, + transferable_days_left=0, + dubbeldagar_left=60, + tier_data=TierData( + sjukpenning_total=390, + sjukpenning_used=0, + sjukpenning_left=390, + lagsta_niva_total=90, + lagsta_niva_used=0, + lagsta_niva_left=90, + ), + ) + result = AnalyticsService._pl_year_series( + years, [], birth, b4, savable_cap=48, latest_fetch=fetch, default_share=None + ) + by_year = {r.year: r for r in result} + + # Untouched pool (390 + 90 = 480) clamped to 48 at b4, lagsta cut first. + assert by_year[2029].lagsta_left == 0 + assert by_year[2029].sjukpenning_left == 48 + # Years before b4 are unaffected by the clamp. + assert by_year[2028].sjukpenning_left == 390 + assert by_year[2028].lagsta_left == 90 + + +class TestYearBalancesOnProjection: + """`Age4Projection.year_balances` end-to-end through the real repos.""" + + async def test_series_spans_birth_year_through_b4_year( + self, + child_repo: SQLiteChildRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, + seeded_ids: SeededIds, + ) -> None: + ws = seeded_ids.workspace_id + pd_uuid = seeded_ids.personal_data_uuid + birth = date(2025, 3, 1) + child = _make_child(ws, birth) + await child_repo.create(child) + + service = await _make_service( + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, + ) + proj = await service.get_days_left_at_age_4(pd_uuid, child.id) + + assert [yb.year for yb in proj.year_balances] == [2025, 2026, 2027, 2028, 2029] + assert all(yb.personal_data_uuid == pd_uuid for yb in proj.year_balances) + assert all(yb.child_id == child.id for yb in proj.year_balances) + + async def test_vacation_planned_use_counts_rows_across_all_children( + self, + child_repo: SQLiteChildRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, + seeded_ids: SeededIds, + ) -> None: + """Vacation is budgeted per (parent, year), not per child — see + + `PlanningService._vacation_days_left`. A vacation day planned + against a DIFFERENT child of the same parent, in the same year, + must still reduce `planned_use` here. + """ + ws = seeded_ids.workspace_id + pd_uuid = seeded_ids.personal_data_uuid + birth = date(2025, 3, 1) + child = _make_child(ws, birth) + await child_repo.create(child) + other_child = _make_child(ws, date(2020, 1, 1)) + await child_repo.create(other_child) + + from app.entities.planned_leave import PlannedLeaveDay + + await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=ws, + child_id=other_child.id, + parent_personal_data_uuid=pd_uuid, + date=date(2025, 6, 1), + day_type="vacation", + ) + ) + + service = await _make_service( + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, + ) + proj = await service.get_days_left_at_age_4(pd_uuid, child.id) + year_2025 = next(yb for yb in proj.year_balances if yb.year == 2025) + + assert year_2025.vacation.planned_use == 1 + + +class TestOverviewYearTotals: + async def test_combines_both_parents_per_year( + self, + child_repo: SQLiteChildRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, + ) -> None: + from app.entities.personal_data import PersonalData + from app.entities.workspace import Workspace + + now = datetime.now() + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + parent_a = await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now) + ) + parent_b = await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="B", created_at=now) + ) + birth = date(2025, 1, 1) + child = _make_child(ws.id, birth) + await child_repo.create(child) + + service = await _make_service( + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, + ) + overview = await service.get_overview(ws.id, child.id) + + assert [yt.year for yt in overview.year_totals] == [2025, 2026, 2027, 2028, 2029] + birth_year_totals = overview.year_totals[0] + parent_a_proj = next( + p for p in overview.parent_projections if p.personal_data_uuid == parent_a.uuid + ) + parent_b_proj = next( + p for p in overview.parent_projections if p.personal_data_uuid == parent_b.uuid + ) + a_2025 = next(yb for yb in parent_a_proj.year_balances if yb.year == 2025) + b_2025 = next(yb for yb in parent_b_proj.year_balances if yb.year == 2025) + + expected_pl_left = ( + a_2025.parental_leave.sjukpenning_left + + a_2025.parental_leave.lagsta_left + + b_2025.parental_leave.sjukpenning_left + + b_2025.parental_leave.lagsta_left + ) + expected_vacation_left = a_2025.vacation.left + b_2025.vacation.left + + assert birth_year_totals.pl_left == expected_pl_left + assert birth_year_totals.vacation_left == expected_vacation_left diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx index 04ac415..3d7477d 100644 --- a/frontend/src/pages/Dashboard.test.tsx +++ b/frontend/src/pages/Dashboard.test.tsx @@ -74,6 +74,7 @@ describe('Dashboard', () => { combinedDaysUsed: 30, combinedDaysLeft: 270, combinedExpiringAtB4: 100, + yearTotals: [], }, }) ) @@ -247,11 +248,12 @@ describe('Dashboard', () => { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2029-06-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }, + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2029-06-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] }, ], combinedDaysUsed: 30, combinedDaysLeft: 210, combinedExpiringAtB4: 60, + yearTotals: [], }, }) ) @@ -268,12 +270,13 @@ describe('Dashboard', () => { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48 }, - { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 1, plannedDaysBeforeB4: 0, projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48 }, + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48, yearBalances: [] }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 1, plannedDaysBeforeB4: 0, projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48, yearBalances: [] }, ], combinedDaysUsed: 31, combinedDaysLeft: 279, combinedExpiringAtB4: 191, + yearTotals: [], }, }) ) @@ -405,12 +408,13 @@ describe('Dashboard', () => { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }, - { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 }, + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] }, ], combinedDaysUsed: 50, combinedDaysLeft: 190, combinedExpiringAtB4: 104, + yearTotals: [], }, }) ) @@ -563,6 +567,7 @@ describe('Dashboard', () => { combinedDaysUsed: 50, combinedDaysLeft: 200, combinedExpiringAtB4: 104, + yearTotals: [], }, }) ) @@ -578,11 +583,12 @@ describe('Dashboard', () => { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104, savableCapAtB4: 96 }, + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104, savableCapAtB4: 96, yearBalances: [] }, ], combinedDaysUsed: 30, combinedDaysLeft: 210, combinedExpiringAtB4: 104, + yearTotals: [], }, }) ) @@ -599,12 +605,13 @@ describe('Dashboard', () => { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }, - { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 }, + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] }, ], combinedDaysUsed: 50, combinedDaysLeft: 190, combinedExpiringAtB4: 104, + yearTotals: [], }, }) ) @@ -633,12 +640,13 @@ describe('Dashboard', () => { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }, - { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 }, + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] }, ], combinedDaysUsed: 50, combinedDaysLeft: 190, combinedExpiringAtB4: 104, + yearTotals: [], }, }) ) @@ -655,12 +663,13 @@ describe('Dashboard', () => { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }, - { personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 }, + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] }, + { personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] }, ], combinedDaysUsed: 50, combinedDaysLeft: 190, combinedExpiringAtB4: 104, + yearTotals: [], }, }) ) diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 8e4bfe1..72f2591 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -33,11 +33,36 @@ describe('API response mappers (snake_case → camelCase)', () => { savable_after_b4: 96, expiring_at_b4: 134, savable_cap_at_b4: 96, + year_balances: [ + { + personal_data_uuid: 'pd1', + child_id: 'child1', + year: 2024, + vacation: { + year: 2024, + earned: 25, + is_assumed: true, + carried_in: 0, + planned_use: 2, + left: 23, + carried_out: 5, + expiring: 0, + }, + parental_leave: { + year: 2024, + sjukpenning_used: 10, + lagsta_used: 0, + sjukpenning_left: 380, + lagsta_left: 90, + }, + }, + ], }, ], combined_days_used: 100, combined_days_left: 380, combined_expiring_at_b4: 134, + year_totals: [{ year: 2024, pl_left: 470, vacation_left: 23 }], } fetchMock.mockResolvedValue({ @@ -61,6 +86,19 @@ describe('API response mappers (snake_case → camelCase)', () => { expect(result.parentProjections[0]!.savableAfterB4).toBe(96) expect(result.parentProjections[0]!.expiringAtB4).toBe(134) expect(result.parentProjections[0]!.savableCapAtB4).toBe(96) + + const yearBalances = result.parentProjections[0]!.yearBalances + expect(yearBalances.length).toBe(1) + expect(yearBalances[0]!.personalDataUuid).toBe('pd1') + expect(yearBalances[0]!.childId).toBe('child1') + expect(yearBalances[0]!.year).toBe(2024) + expect(yearBalances[0]!.vacation.isAssumed).toBe(true) + expect(yearBalances[0]!.vacation.plannedUse).toBe(2) + expect(yearBalances[0]!.vacation.carriedOut).toBe(5) + expect(yearBalances[0]!.parentalLeave.sjukpenningUsed).toBe(10) + expect(yearBalances[0]!.parentalLeave.sjukpenningLeft).toBe(380) + + expect(result.yearTotals).toEqual([{ year: 2024, plLeft: 470, vacationLeft: 23 }]) }) }) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4940dfe..8e4531f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -38,6 +38,10 @@ import type { PersonalData, CalendarWindow, CalendarDayEntry, + VacationYearBalance, + ParentalLeaveYearBalance, + ParentYearBalance, + YearTotals, } from '@/types' const BASE = '/api' @@ -106,6 +110,82 @@ function toParentalLeaveFetch(raw: RawParentalLeaveFetch): ParentalLeaveFetch { } } +export interface RawVacationYearBalance { + year: number + earned: number + is_assumed: boolean + carried_in: number + planned_use: number + left: number + carried_out: number + expiring: number +} + +function toVacationYearBalance(raw: RawVacationYearBalance): VacationYearBalance { + return { + year: raw.year, + earned: raw.earned, + isAssumed: raw.is_assumed, + carriedIn: raw.carried_in, + plannedUse: raw.planned_use, + left: raw.left, + carriedOut: raw.carried_out, + expiring: raw.expiring, + } +} + +export interface RawParentalLeaveYearBalance { + year: number + sjukpenning_used: number + lagsta_used: number + sjukpenning_left: number + lagsta_left: number +} + +function toParentalLeaveYearBalance( + raw: RawParentalLeaveYearBalance, +): ParentalLeaveYearBalance { + return { + year: raw.year, + sjukpenningUsed: raw.sjukpenning_used, + lagstaUsed: raw.lagsta_used, + sjukpenningLeft: raw.sjukpenning_left, + lagstaLeft: raw.lagsta_left, + } +} + +export interface RawParentYearBalance { + personal_data_uuid: string + child_id: string + year: number + vacation: RawVacationYearBalance + parental_leave: RawParentalLeaveYearBalance +} + +function toParentYearBalance(raw: RawParentYearBalance): ParentYearBalance { + return { + personalDataUuid: raw.personal_data_uuid, + childId: raw.child_id, + year: raw.year, + vacation: toVacationYearBalance(raw.vacation), + parentalLeave: toParentalLeaveYearBalance(raw.parental_leave), + } +} + +export interface RawYearTotals { + year: number + pl_left: number + vacation_left: number +} + +function toYearTotals(raw: RawYearTotals): YearTotals { + return { + year: raw.year, + plLeft: raw.pl_left, + vacationLeft: raw.vacation_left, + } +} + export interface RawAge4Projection { personal_data_uuid: string child_id: string @@ -118,6 +198,7 @@ export interface RawAge4Projection { savable_after_b4: number expiring_at_b4: number savable_cap_at_b4: number + year_balances: RawParentYearBalance[] } function toAge4Projection(raw: RawAge4Projection): Age4Projection { @@ -133,6 +214,7 @@ function toAge4Projection(raw: RawAge4Projection): Age4Projection { savableAfterB4: raw.savable_after_b4, expiringAtB4: raw.expiring_at_b4, savableCapAtB4: raw.savable_cap_at_b4, + yearBalances: raw.year_balances.map(toParentYearBalance), } } @@ -143,6 +225,7 @@ export interface RawWorkspaceOverview { combined_days_used: number combined_days_left: number combined_expiring_at_b4: number + year_totals: RawYearTotals[] } function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview { @@ -153,6 +236,7 @@ function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview { combinedDaysUsed: raw.combined_days_used, combinedDaysLeft: raw.combined_days_left, combinedExpiringAtB4: raw.combined_expiring_at_b4, + yearTotals: raw.year_totals.map(toYearTotals), } } diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index f8cfaeb..64a9144 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -220,6 +220,53 @@ export interface CalendarWindow { days: CalendarDayEntry[] } +// Per (parent, child, calendar year) balance series shown on the analytics +// overview — birth year through the year of the 4th birthday inclusive. See +// the backend's `app.services.analytics_service.ParentYearBalance` docstring +// for the vacation carry-forward (Semesterlagen, 5-year expiry) algorithm. + +export interface VacationYearBalance { + year: number + /** Entitlement for this year: the entered total, or the statutory default (25). */ + earned: number + /** True when no `vacation_days` row exists for this (parent, year) — `earned` is the statutory default. */ + isAssumed: boolean + /** Days carried in from prior years, still valid. */ + carriedIn: number + /** Committed 'vacation' planned-leave rows in this year (across all of this parent's children). */ + plannedUse: number + /** max(earned + carriedIn - plannedUse, 0). */ + left: number + /** Days saved forward into next year's carriedIn. */ + carriedOut: number + /** Days that hit the 5-year carry-forward cap this year — LOST if not used. */ + expiring: number +} + +export interface ParentalLeaveYearBalance { + year: number + sjukpenningUsed: number + lagstaUsed: number + /** Running balance at this year's end (age-4 clamp applied in the 4th-birthday year). */ + sjukpenningLeft: number + lagstaLeft: number +} + +export interface ParentYearBalance { + personalDataUuid: string + childId: string + year: number + vacation: VacationYearBalance + parentalLeave: ParentalLeaveYearBalance +} + +/** Combined (all parents) balance for one calendar year, on the overview. */ +export interface YearTotals { + year: number + plLeft: number + vacationLeft: number +} + export interface Age4Projection { personalDataUuid: string childId: string @@ -236,6 +283,8 @@ export interface Age4Projection { // parents. See `resolve_age4_savable_cap` (backend). Render the per-parent // bar against this field directly; never re-derive the 48-vs-96 rule here. savableCapAtB4: number + // Per-calendar-year vacation + FP balance series — see `ParentYearBalance`. + yearBalances: ParentYearBalance[] } export interface WorkspaceOverview { @@ -245,6 +294,8 @@ export interface WorkspaceOverview { combinedDaysUsed: number combinedDaysLeft: number combinedExpiringAtB4: number + // Combined (all parents) per-year totals — see `YearTotals`. + yearTotals: YearTotals[] } export interface Tip { diff --git a/frontend/tests/fixtures/analytics.ts b/frontend/tests/fixtures/analytics.ts index de8ed85..25b0d1a 100644 --- a/frontend/tests/fixtures/analytics.ts +++ b/frontend/tests/fixtures/analytics.ts @@ -1,6 +1,64 @@ /** Shared test fixtures — Dashboard analytics (`GET /api/analytics/overview`). */ -import type { Age4Projection, WorkspaceOverview } from '@/types' +import type { + Age4Projection, + ParentalLeaveYearBalance, + ParentYearBalance, + VacationYearBalance, + WorkspaceOverview, + YearTotals, +} from '@/types' + +export function makeVacationYearBalance( + overrides: Partial = {}, +): VacationYearBalance { + return { + year: 2025, + earned: 25, + isAssumed: true, + carriedIn: 0, + plannedUse: 0, + left: 25, + carriedOut: 5, + expiring: 0, + ...overrides, + } +} + +export function makeParentalLeaveYearBalance( + overrides: Partial = {}, +): ParentalLeaveYearBalance { + return { + year: 2025, + sjukpenningUsed: 0, + lagstaUsed: 0, + sjukpenningLeft: 390, + lagstaLeft: 90, + ...overrides, + } +} + +export function makeParentYearBalance( + overrides: Partial = {}, +): ParentYearBalance { + return { + personalDataUuid: 'pd-a', + childId: 'child-1', + year: 2025, + vacation: makeVacationYearBalance(), + parentalLeave: makeParentalLeaveYearBalance(), + ...overrides, + } +} + +export function makeYearTotals(overrides: Partial = {}): YearTotals { + return { + year: 2025, + plLeft: 480, + vacationLeft: 25, + ...overrides, + } +} export function makeAge4Projection(overrides: Partial = {}): Age4Projection { return { @@ -15,6 +73,7 @@ export function makeAge4Projection(overrides: Partial = {}): Age savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, + yearBalances: [], ...overrides, } } @@ -27,6 +86,7 @@ export function makeWorkspaceOverview(overrides: Partial = {} combinedDaysUsed: 30, combinedDaysLeft: 270, combinedExpiringAtB4: 100, + yearTotals: [], ...overrides, } }