Give the analytics overview a per-parent, per-year balance series

Each parent projection now carries one entry per calendar year from
birth to the fourth birthday: vacation earned, carried in, planned,
left, carried out and expiring under Semesterlagen's 20-day floor and
5-year carry-over, plus parental-leave days used and left at year end
with the age-4 clamp applied in the birthday year. The overview sums
them into per-year totals. Frontend types, mapper and fixtures follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-19 19:02:02 +02:00
parent dbef997da4
commit 4b802ba0be
14 changed files with 1365 additions and 37 deletions
+6
View File
@@ -62,6 +62,12 @@ class LeaveRules(BaseModel):
# allocator actually enforces (PLANNING_ENGINE_SPEC.md §9.2). # allocator actually enforces (PLANNING_ENGINE_SPEC.md §9.2).
min_vacation_days_per_year: int = 20 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 @property
def total_days_per_child(self) -> int: def total_days_per_child(self) -> int:
return self.sjukpenning_days + self.lagsta_niva_days return self.sjukpenning_days + self.lagsta_niva_days
+4 -1
View File
@@ -354,8 +354,11 @@ def get_analytics_service(
child_repo: ChildRepositoryDep, child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep, pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep, workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
) -> AnalyticsService: ) -> 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)] AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)]
+82 -1
View File
@@ -5,7 +5,84 @@ from uuid import UUID
from pydantic import BaseModel 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): class Age4ProjectionResponse(BaseModel):
@@ -20,6 +97,7 @@ class Age4ProjectionResponse(BaseModel):
savable_after_b4: int savable_after_b4: int
expiring_at_b4: int expiring_at_b4: int
savable_cap_at_b4: int savable_cap_at_b4: int
year_balances: list[ParentYearBalanceResponse]
@classmethod @classmethod
def from_entity(cls, p: Age4Projection) -> "Age4ProjectionResponse": def from_entity(cls, p: Age4Projection) -> "Age4ProjectionResponse":
@@ -35,6 +113,7 @@ class Age4ProjectionResponse(BaseModel):
savable_after_b4=p.savable_after_b4, savable_after_b4=p.savable_after_b4,
expiring_at_b4=p.expiring_at_b4, expiring_at_b4=p.expiring_at_b4,
savable_cap_at_b4=p.savable_cap_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_used: int
combined_days_left: int combined_days_left: int
combined_expiring_at_b4: int combined_expiring_at_b4: int
year_totals: list[YearTotalsResponse]
@classmethod @classmethod
def from_entity(cls, overview: WorkspaceOverview) -> "WorkspaceOverviewResponse": def from_entity(cls, overview: WorkspaceOverview) -> "WorkspaceOverviewResponse":
@@ -57,4 +137,5 @@ class WorkspaceOverviewResponse(BaseModel):
combined_days_used=overview.combined_days_used, combined_days_used=overview.combined_days_used,
combined_days_left=overview.combined_days_left, combined_days_left=overview.combined_days_left,
combined_expiring_at_b4=overview.combined_expiring_at_b4, combined_expiring_at_b4=overview.combined_expiring_at_b4,
year_totals=[YearTotalsResponse.from_entity(y) for y in overview.year_totals],
) )
+17 -4
View File
@@ -9,6 +9,7 @@ from app.dependencies import (
ParentalLeaveRepositoryDep, ParentalLeaveRepositoryDep,
PersonalDataRepositoryDep, PersonalDataRepositoryDep,
PlannedLeaveRepositoryDep, PlannedLeaveRepositoryDep,
VacationDaysRepositoryDep,
WorkspaceIdDep, WorkspaceIdDep,
WorkspaceRepositoryDep, WorkspaceRepositoryDep,
WorkspaceScopeDep, WorkspaceScopeDep,
@@ -26,8 +27,11 @@ def _make_service(
child_repo: ChildRepositoryDep, child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep, pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep, workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
) -> AnalyticsService: ) -> 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) @router.get("/{workspace_id}/overview", response_model=WorkspaceOverviewResponse)
@@ -39,8 +43,11 @@ async def get_overview(
child_repo: ChildRepositoryDep, child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep, pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep, workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
) -> WorkspaceOverviewResponse: ) -> 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: try:
overview = await service.get_overview(workspace_id, child_id) overview = await service.get_overview(workspace_id, child_id)
except NotFoundError as exc: except NotFoundError as exc:
@@ -57,8 +64,11 @@ async def get_projection(
child_repo: ChildRepositoryDep, child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep, pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep, workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
) -> WorkspaceOverviewResponse: ) -> 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: try:
overview = await service.get_overview(workspace_id, child_id) overview = await service.get_overview(workspace_id, child_id)
except NotFoundError as exc: except NotFoundError as exc:
@@ -77,11 +87,14 @@ async def get_age4_projection(
child_repo: ChildRepositoryDep, child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep, pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep, workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep, scope: WorkspaceScopeDep,
) -> Age4ProjectionResponse: ) -> Age4ProjectionResponse:
await scope.check_member(personal_data_uuid) await scope.check_member(personal_data_uuid)
await scope.check_child(child_id) 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: try:
proj = await service.get_days_left_at_age_4( proj = await service.get_days_left_at_age_4(
personal_data_uuid, child_id, workspace_id=scope.workspace_id personal_data_uuid, child_id, workspace_id=scope.workspace_id
+331 -1
View File
@@ -18,17 +18,22 @@ dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import date from datetime import date
from uuid import UUID from uuid import UUID
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from app.config import settings 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.child_repository import ChildRepository
from app.repositories.parental_leave_repository import ParentalLeaveRepository from app.repositories.parental_leave_repository import ParentalLeaveRepository
from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository 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.repositories.workspace_repository import WorkspaceRepository
from app.utils.errors import DubbeldagError, NotFoundError from app.utils.errors import DubbeldagError, NotFoundError
from app.utils.tip_predicates import ( 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 @dataclass
class Age4Projection: class Age4Projection:
"""Per-parent projection of days remaining at the child's 4th birthday.""" """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 # `resolve_age4_savable_cap`. Surfaced so the frontend can render "X kvar
# / Y sparbara" without re-deriving the rule itself. # / Y sparbara" without re-deriving the rule itself.
savable_cap_at_b4: int 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 @dataclass
@@ -67,6 +154,8 @@ class WorkspaceOverview:
combined_days_used: int combined_days_used: int
combined_days_left: int combined_days_left: int
combined_expiring_at_b4: int combined_expiring_at_b4: int
# Combined (all parents) per-year totals — see `YearTotals`.
year_totals: list[YearTotals] = field(default_factory=list)
class AnalyticsService: class AnalyticsService:
@@ -77,12 +166,14 @@ class AnalyticsService:
child_repo: ChildRepository, child_repo: ChildRepository,
personal_data_repo: PersonalDataRepository, personal_data_repo: PersonalDataRepository,
workspace_repo: WorkspaceRepository, workspace_repo: WorkspaceRepository,
vacation_days_repo: VacationDaysRepository,
) -> None: ) -> None:
self._leave = parental_leave_repo self._leave = parental_leave_repo
self._planned = planned_leave_repo self._planned = planned_leave_repo
self._children = child_repo self._children = child_repo
self._parents = personal_data_repo self._parents = personal_data_repo
self._workspace_repo = workspace_repo self._workspace_repo = workspace_repo
self._vacation = vacation_days_repo
async def get_parent_count(self, workspace_id: UUID) -> int: async def get_parent_count(self, workspace_id: UUID) -> int:
"""Number of (non-deleted) parents on a workspace. """Number of (non-deleted) parents on a workspace.
@@ -134,6 +225,7 @@ class AnalyticsService:
b4 = child.birth_date + relativedelta(years=4) b4 = child.birth_date + relativedelta(years=4)
latest = await self._leave.get_latest_by_parent(personal_data_uuid, child_id) latest = await self._leave.get_latest_by_parent(personal_data_uuid, child_id)
default_share: tuple[int, int] | None = None
if latest is None: if latest is None:
# No FK data yet — assume full entitlement (0 used) for this # No FK data yet — assume full entitlement (0 used) for this
# parent's share, via the same shared helper `AllocationInputLoader. # 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) parent_count = await self.get_parent_count(child.workspace_id)
sjuk_share, lagsta_share = default_tier_pool_share(rules, parent_count) sjuk_share, lagsta_share = default_tier_pool_share(rules, parent_count)
total_days = sjuk_share + lagsta_share total_days = sjuk_share + lagsta_share
default_share = (sjuk_share, lagsta_share)
else: else:
days_used = latest.days_used days_used = latest.days_used
total_days = latest.total_days total_days = latest.total_days
@@ -180,6 +273,10 @@ class AnalyticsService:
) )
savable = min(projected_left, savable_cap) 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( return Age4Projection(
personal_data_uuid=personal_data_uuid, personal_data_uuid=personal_data_uuid,
child_id=child_id, child_id=child_id,
@@ -192,8 +289,228 @@ class AnalyticsService:
savable_after_b4=savable, savable_after_b4=savable,
expiring_at_b4=expiring, expiring_at_b4=expiring,
savable_cap_at_b4=savable_cap, 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: async def get_overview(self, workspace_id: UUID, child_id: UUID) -> WorkspaceOverview:
"""Compute projections for all parents in a workspace for one child.""" """Compute projections for all parents in a workspace for one child."""
members = await self._parents.list_by_workspace(workspace_id) 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_left = sum(p.projected_left_at_b4 for p in projections)
combined_expiring = sum(p.expiring_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( return WorkspaceOverview(
workspace_id=workspace_id, workspace_id=workspace_id,
child_id=child_id, child_id=child_id,
@@ -216,6 +545,7 @@ class AnalyticsService:
combined_days_used=combined_used, combined_days_used=combined_used,
combined_days_left=combined_left, combined_days_left=combined_left,
combined_expiring_at_b4=combined_expiring, combined_expiring_at_b4=combined_expiring,
year_totals=year_totals,
) )
async def validate_dubbeldagar(self, child_id: UUID, new_date: date) -> None: async def validate_dubbeldagar(self, child_id: UUID, new_date: date) -> None:
+3 -1
View File
@@ -118,7 +118,9 @@ def _build_services(conn: aiosqlite.Connection) -> DemoServices:
parental_leave = SQLiteParentalLeaveRepository(conn) parental_leave = SQLiteParentalLeaveRepository(conn)
planned_leave = SQLitePlannedLeaveRepository(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( return DemoServices(
auth=AuthService(users, personal_data, workspaces), auth=AuthService(users, personal_data, workspaces),
+7 -1
View File
@@ -337,9 +337,15 @@ async def analytics_service(
child_repo: SQLiteChildRepository, child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> AnalyticsService: ) -> AnalyticsService:
return 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,
) )
@@ -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
@@ -12,6 +12,7 @@ from dateutil.relativedelta import relativedelta
from app.entities.child import Child from app.entities.child import Child
from app.entities.parental_leave import ParentalLeaveFetch, TierData 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.child_repository import SQLiteChildRepository
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
@@ -71,8 +72,11 @@ async def _make_service(
planned_repo: SQLitePlannedLeaveRepository, planned_repo: SQLitePlannedLeaveRepository,
pd_repo: SQLitePersonalDataRepository, pd_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> AnalyticsService: ) -> 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: class TestAge4Projection:
@@ -83,6 +87,7 @@ class TestAge4Projection:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds, seeded_ids: SeededIds,
) -> None: ) -> None:
ws = seeded_ids.workspace_id ws = seeded_ids.workspace_id
@@ -97,7 +102,12 @@ class TestAge4Projection:
await parental_leave_repo.create(fetch) await parental_leave_repo.create(fetch)
service = await _make_service( 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) proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -115,6 +125,7 @@ class TestAge4Projection:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds, seeded_ids: SeededIds,
) -> None: ) -> None:
ws = seeded_ids.workspace_id ws = seeded_ids.workspace_id
@@ -125,7 +136,12 @@ class TestAge4Projection:
# No FK data entered # No FK data entered
service = await _make_service( 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) proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -145,6 +161,7 @@ class TestAge4Projection:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds, seeded_ids: SeededIds,
) -> None: ) -> None:
ws = seeded_ids.workspace_id ws = seeded_ids.workspace_id
@@ -155,7 +172,12 @@ class TestAge4Projection:
await child_repo.create(child) await child_repo.create(child)
service = await _make_service( 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) proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -169,6 +191,7 @@ class TestAge4Projection:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds, seeded_ids: SeededIds,
) -> None: ) -> None:
ws = seeded_ids.workspace_id ws = seeded_ids.workspace_id
@@ -181,7 +204,12 @@ class TestAge4Projection:
await parental_leave_repo.create(fetch) await parental_leave_repo.create(fetch)
service = await _make_service( 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) proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -196,11 +224,17 @@ class TestAge4Projection:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None: ) -> None:
from app.utils.errors import NotFoundError from app.utils.errors import NotFoundError
service = await _make_service( 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): with pytest.raises(NotFoundError):
await service.get_days_left_at_age_4(uuid4(), uuid4()) await service.get_days_left_at_age_4(uuid4(), uuid4())
@@ -214,11 +248,17 @@ class TestValidateDubbeldagar:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None: ) -> None:
from app.utils.errors import NotFoundError from app.utils.errors import NotFoundError
service = await _make_service( 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): with pytest.raises(NotFoundError):
await service.validate_dubbeldagar(uuid4(), date.today()) await service.validate_dubbeldagar(uuid4(), date.today())
@@ -230,6 +270,7 @@ class TestValidateDubbeldagar:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds, seeded_ids: SeededIds,
) -> None: ) -> None:
from app.utils.errors import DubbeldagError from app.utils.errors import DubbeldagError
@@ -240,7 +281,12 @@ class TestValidateDubbeldagar:
await child_repo.create(child) await child_repo.create(child)
service = await _make_service( 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 # Date beyond the 15-month window
beyond_window = birth + relativedelta(months=16) beyond_window = birth + relativedelta(months=16)
@@ -254,6 +300,7 @@ class TestValidateDubbeldagar:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds, seeded_ids: SeededIds,
) -> None: ) -> None:
from app.config import settings from app.config import settings
@@ -282,7 +329,12 @@ class TestValidateDubbeldagar:
) )
service = await _make_service( 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) within_window = birth + relativedelta(months=1)
with pytest.raises(DubbeldagError): with pytest.raises(DubbeldagError):
@@ -297,6 +349,7 @@ class TestGetOverview:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds, seeded_ids: SeededIds,
) -> None: ) -> None:
ws = seeded_ids.workspace_id ws = seeded_ids.workspace_id
@@ -310,7 +363,12 @@ class TestGetOverview:
await parental_leave_repo.create(fetch) await parental_leave_repo.create(fetch)
service = await _make_service( 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) overview = await service.get_overview(ws, child.id)
@@ -327,11 +385,17 @@ class TestGetOverview:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None: ) -> None:
from app.utils.errors import NotFoundError from app.utils.errors import NotFoundError
service = await _make_service( 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): with pytest.raises(NotFoundError):
await service.get_overview(uuid4(), uuid4()) await service.get_overview(uuid4(), uuid4())
@@ -353,6 +417,7 @@ class TestCoupleWorkspacePerParentCap:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None: ) -> None:
from app.entities.personal_data import PersonalData from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace from app.entities.workspace import Workspace
@@ -378,7 +443,12 @@ class TestCoupleWorkspacePerParentCap:
await parental_leave_repo.create(fetch_b) await parental_leave_repo.create(fetch_b)
service = await _make_service( 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) 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, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None: ) -> None:
"""Safety belt: a workspace with only one member so far (partner not """Safety belt: a workspace with only one member so far (partner not
@@ -423,7 +494,12 @@ class TestCoupleWorkspacePerParentCap:
await parental_leave_repo.create(fetch) await parental_leave_repo.create(fetch)
service = await _make_service( 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) proj = await service.get_days_left_at_age_4(parent.uuid, child.id)
assert proj.savable_cap_at_b4 == 96 assert proj.savable_cap_at_b4 == 96
@@ -446,6 +522,7 @@ class TestSingleParentWorkspaceCap:
planned_leave_repo: SQLitePlannedLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds, seeded_ids: SeededIds,
) -> None: ) -> None:
ws = seeded_ids.workspace_id ws = seeded_ids.workspace_id
@@ -457,7 +534,12 @@ class TestSingleParentWorkspaceCap:
await parental_leave_repo.create(fetch) await parental_leave_repo.create(fetch)
service = await _make_service( 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) proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert proj.savable_cap_at_b4 == 96 assert proj.savable_cap_at_b4 == 96
@@ -515,7 +597,12 @@ class TestAgreesWithPlanProposalForTheSameCommittedState:
# -- Dashboard side -- # -- Dashboard side --
analytics = await _make_service( 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) 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. # fallback (240 vs. 480) fails loudly rather than merely disagreeing.
assert dashboard_proj.projected_left_at_b4 == 480 assert dashboard_proj.projected_left_at_b4 == 480
assert dashboard_proj.expiring_at_b4 == 384 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
+21 -12
View File
@@ -74,6 +74,7 @@ describe('Dashboard', () => {
combinedDaysUsed: 30, combinedDaysUsed: 30,
combinedDaysLeft: 270, combinedDaysLeft: 270,
combinedExpiringAtB4: 100, combinedExpiringAtB4: 100,
yearTotals: [],
}, },
}) })
) )
@@ -247,11 +248,12 @@ describe('Dashboard', () => {
workspaceId: 'ws-1', workspaceId: 'ws-1',
childId: 'c-1', childId: 'c-1',
parentProjections: [ 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, combinedDaysUsed: 30,
combinedDaysLeft: 210, combinedDaysLeft: 210,
combinedExpiringAtB4: 60, combinedExpiringAtB4: 60,
yearTotals: [],
}, },
}) })
) )
@@ -268,12 +270,13 @@ describe('Dashboard', () => {
workspaceId: 'ws-1', workspaceId: 'ws-1',
childId: 'c-1', childId: 'c-1',
parentProjections: [ 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-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 }, { 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, combinedDaysUsed: 31,
combinedDaysLeft: 279, combinedDaysLeft: 279,
combinedExpiringAtB4: 191, combinedExpiringAtB4: 191,
yearTotals: [],
}, },
}) })
) )
@@ -405,12 +408,13 @@ describe('Dashboard', () => {
workspaceId: 'ws-1', workspaceId: 'ws-1',
childId: 'c-1', childId: 'c-1',
parentProjections: [ 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-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 }, { 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, combinedDaysUsed: 50,
combinedDaysLeft: 190, combinedDaysLeft: 190,
combinedExpiringAtB4: 104, combinedExpiringAtB4: 104,
yearTotals: [],
}, },
}) })
) )
@@ -563,6 +567,7 @@ describe('Dashboard', () => {
combinedDaysUsed: 50, combinedDaysUsed: 50,
combinedDaysLeft: 200, combinedDaysLeft: 200,
combinedExpiringAtB4: 104, combinedExpiringAtB4: 104,
yearTotals: [],
}, },
}) })
) )
@@ -578,11 +583,12 @@ describe('Dashboard', () => {
workspaceId: 'ws-1', workspaceId: 'ws-1',
childId: 'c-1', childId: 'c-1',
parentProjections: [ 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, combinedDaysUsed: 30,
combinedDaysLeft: 210, combinedDaysLeft: 210,
combinedExpiringAtB4: 104, combinedExpiringAtB4: 104,
yearTotals: [],
}, },
}) })
) )
@@ -599,12 +605,13 @@ describe('Dashboard', () => {
workspaceId: 'ws-1', workspaceId: 'ws-1',
childId: 'c-1', childId: 'c-1',
parentProjections: [ 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-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 }, { 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, combinedDaysUsed: 50,
combinedDaysLeft: 190, combinedDaysLeft: 190,
combinedExpiringAtB4: 104, combinedExpiringAtB4: 104,
yearTotals: [],
}, },
}) })
) )
@@ -633,12 +640,13 @@ describe('Dashboard', () => {
workspaceId: 'ws-1', workspaceId: 'ws-1',
childId: 'c-1', childId: 'c-1',
parentProjections: [ 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-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 }, { 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, combinedDaysUsed: 50,
combinedDaysLeft: 190, combinedDaysLeft: 190,
combinedExpiringAtB4: 104, combinedExpiringAtB4: 104,
yearTotals: [],
}, },
}) })
) )
@@ -655,12 +663,13 @@ describe('Dashboard', () => {
workspaceId: 'ws-1', workspaceId: 'ws-1',
childId: 'c-1', childId: 'c-1',
parentProjections: [ 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-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 }, { 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, combinedDaysUsed: 50,
combinedDaysLeft: 190, combinedDaysLeft: 190,
combinedExpiringAtB4: 104, combinedExpiringAtB4: 104,
yearTotals: [],
}, },
}) })
) )
+38
View File
@@ -33,11 +33,36 @@ describe('API response mappers (snake_case → camelCase)', () => {
savable_after_b4: 96, savable_after_b4: 96,
expiring_at_b4: 134, expiring_at_b4: 134,
savable_cap_at_b4: 96, 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_used: 100,
combined_days_left: 380, combined_days_left: 380,
combined_expiring_at_b4: 134, combined_expiring_at_b4: 134,
year_totals: [{ year: 2024, pl_left: 470, vacation_left: 23 }],
} }
fetchMock.mockResolvedValue({ fetchMock.mockResolvedValue({
@@ -61,6 +86,19 @@ describe('API response mappers (snake_case → camelCase)', () => {
expect(result.parentProjections[0]!.savableAfterB4).toBe(96) expect(result.parentProjections[0]!.savableAfterB4).toBe(96)
expect(result.parentProjections[0]!.expiringAtB4).toBe(134) expect(result.parentProjections[0]!.expiringAtB4).toBe(134)
expect(result.parentProjections[0]!.savableCapAtB4).toBe(96) 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 }])
}) })
}) })
+84
View File
@@ -38,6 +38,10 @@ import type {
PersonalData, PersonalData,
CalendarWindow, CalendarWindow,
CalendarDayEntry, CalendarDayEntry,
VacationYearBalance,
ParentalLeaveYearBalance,
ParentYearBalance,
YearTotals,
} from '@/types' } from '@/types'
const BASE = '/api' 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 { export interface RawAge4Projection {
personal_data_uuid: string personal_data_uuid: string
child_id: string child_id: string
@@ -118,6 +198,7 @@ export interface RawAge4Projection {
savable_after_b4: number savable_after_b4: number
expiring_at_b4: number expiring_at_b4: number
savable_cap_at_b4: number savable_cap_at_b4: number
year_balances: RawParentYearBalance[]
} }
function toAge4Projection(raw: RawAge4Projection): Age4Projection { function toAge4Projection(raw: RawAge4Projection): Age4Projection {
@@ -133,6 +214,7 @@ function toAge4Projection(raw: RawAge4Projection): Age4Projection {
savableAfterB4: raw.savable_after_b4, savableAfterB4: raw.savable_after_b4,
expiringAtB4: raw.expiring_at_b4, expiringAtB4: raw.expiring_at_b4,
savableCapAtB4: raw.savable_cap_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_used: number
combined_days_left: number combined_days_left: number
combined_expiring_at_b4: number combined_expiring_at_b4: number
year_totals: RawYearTotals[]
} }
function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview { function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview {
@@ -153,6 +236,7 @@ function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview {
combinedDaysUsed: raw.combined_days_used, combinedDaysUsed: raw.combined_days_used,
combinedDaysLeft: raw.combined_days_left, combinedDaysLeft: raw.combined_days_left,
combinedExpiringAtB4: raw.combined_expiring_at_b4, combinedExpiringAtB4: raw.combined_expiring_at_b4,
yearTotals: raw.year_totals.map(toYearTotals),
} }
} }
+51
View File
@@ -220,6 +220,53 @@ export interface CalendarWindow {
days: CalendarDayEntry[] 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 { export interface Age4Projection {
personalDataUuid: string personalDataUuid: string
childId: string childId: string
@@ -236,6 +283,8 @@ export interface Age4Projection {
// parents. See `resolve_age4_savable_cap` (backend). Render the per-parent // 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. // bar against this field directly; never re-derive the 48-vs-96 rule here.
savableCapAtB4: number savableCapAtB4: number
// Per-calendar-year vacation + FP balance series — see `ParentYearBalance`.
yearBalances: ParentYearBalance[]
} }
export interface WorkspaceOverview { export interface WorkspaceOverview {
@@ -245,6 +294,8 @@ export interface WorkspaceOverview {
combinedDaysUsed: number combinedDaysUsed: number
combinedDaysLeft: number combinedDaysLeft: number
combinedExpiringAtB4: number combinedExpiringAtB4: number
// Combined (all parents) per-year totals — see `YearTotals`.
yearTotals: YearTotals[]
} }
export interface Tip { export interface Tip {
+61 -1
View File
@@ -1,6 +1,64 @@
/** Shared test fixtures — Dashboard analytics (`GET /api/analytics/overview`). */ /** 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> = {},
): 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> = {},
): ParentalLeaveYearBalance {
return {
year: 2025,
sjukpenningUsed: 0,
lagstaUsed: 0,
sjukpenningLeft: 390,
lagstaLeft: 90,
...overrides,
}
}
export function makeParentYearBalance(
overrides: Partial<ParentYearBalance> = {},
): ParentYearBalance {
return {
personalDataUuid: 'pd-a',
childId: 'child-1',
year: 2025,
vacation: makeVacationYearBalance(),
parentalLeave: makeParentalLeaveYearBalance(),
...overrides,
}
}
export function makeYearTotals(overrides: Partial<YearTotals> = {}): YearTotals {
return {
year: 2025,
plLeft: 480,
vacationLeft: 25,
...overrides,
}
}
export function makeAge4Projection(overrides: Partial<Age4Projection> = {}): Age4Projection { export function makeAge4Projection(overrides: Partial<Age4Projection> = {}): Age4Projection {
return { return {
@@ -15,6 +73,7 @@ export function makeAge4Projection(overrides: Partial<Age4Projection> = {}): Age
savableAfterB4: 96, savableAfterB4: 96,
expiringAtB4: 60, expiringAtB4: 60,
savableCapAtB4: 96, savableCapAtB4: 96,
yearBalances: [],
...overrides, ...overrides,
} }
} }
@@ -27,6 +86,7 @@ export function makeWorkspaceOverview(overrides: Partial<WorkspaceOverview> = {}
combinedDaysUsed: 30, combinedDaysUsed: 30,
combinedDaysLeft: 270, combinedDaysLeft: 270,
combinedExpiringAtB4: 100, combinedExpiringAtB4: 100,
yearTotals: [],
...overrides, ...overrides,
} }
} }