Link planned days to their wish, fix the age-4 cap, drop the planning goal

Three changes that together unblock treating a calendar period as one
editable object.

Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL)
so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a
day the user hand-edited, and `leave_wish.target_days` so a period drawn on a
calendar need not be a whole number of weeks. Apply-time identity still keys on
day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update
rather than a collision.

The age-4 savable ceiling was applying the per-child figure of 96 to each
parent's pool, modelling a two-parent family as able to save 192 days past the
child's fourth birthday. `resolve_age4_savable_cap` is now the single place
that decides 48 (couple with two parents) versus 96 (everyone else), and both
AnalyticsService and the allocator read it.

Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`.
The allocator's tier-3 policy collapses to one rule; `salary_type` still
branches, since that reflects what a vacation day actually costs a parent
rather than a user preference.
This commit is contained in:
2026-09-15 20:28:06 +02:00
parent 7f516f2cab
commit cc3ced905e
59 changed files with 1427 additions and 740 deletions
@@ -70,8 +70,9 @@ async def _make_service(
leave_repo: SQLiteParentalLeaveRepository,
planned_repo: SQLitePlannedLeaveRepository,
pd_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
) -> AnalyticsService:
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo)
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
class TestAge4Projection:
@@ -81,6 +82,7 @@ class TestAge4Projection:
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -95,7 +97,7 @@ class TestAge4Projection:
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -104,6 +106,7 @@ class TestAge4Projection:
assert proj.projected_left_at_b4 == 140 # 240 - 100
assert proj.savable_after_b4 == 96
assert proj.expiring_at_b4 == 44 # 140 - 96
assert proj.savable_cap_at_b4 == 96 # solo workspace -> per-child ceiling
async def test_no_fetch_data_assumes_full_entitlement(
self,
@@ -111,6 +114,7 @@ class TestAge4Projection:
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -121,7 +125,7 @@ class TestAge4Projection:
# No FK data entered
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -137,6 +141,7 @@ class TestAge4Projection:
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -147,7 +152,7 @@ class TestAge4Projection:
await child_repo.create(child)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -160,6 +165,7 @@ class TestAge4Projection:
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -172,7 +178,7 @@ class TestAge4Projection:
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -186,11 +192,12 @@ class TestAge4Projection:
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
with pytest.raises(NotFoundError):
await service.get_days_left_at_age_4(uuid4(), uuid4())
@@ -203,11 +210,12 @@ class TestValidateDubbeldagar:
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
with pytest.raises(NotFoundError):
await service.validate_dubbeldagar(uuid4(), date.today())
@@ -218,6 +226,7 @@ class TestValidateDubbeldagar:
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
seeded_ids: SeededIds,
) -> None:
from app.utils.errors import DubbeldagError
@@ -228,7 +237,7 @@ class TestValidateDubbeldagar:
await child_repo.create(child)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
# Date beyond the 15-month window
beyond_window = birth + relativedelta(months=16)
@@ -241,6 +250,7 @@ class TestValidateDubbeldagar:
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
seeded_ids: SeededIds,
) -> None:
from app.config import settings
@@ -269,7 +279,7 @@ class TestValidateDubbeldagar:
)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
within_window = birth + relativedelta(months=1)
with pytest.raises(DubbeldagError):
@@ -283,6 +293,7 @@ class TestGetOverview:
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -296,7 +307,7 @@ class TestGetOverview:
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
overview = await service.get_overview(ws, child.id)
@@ -312,16 +323,115 @@ class TestGetOverview:
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
with pytest.raises(NotFoundError):
await service.get_overview(uuid4(), uuid4())
class TestCoupleWorkspacePerParentCap:
"""Headline regression: a two-parent `couple` workspace must cap each
parent's own savable balance at 48 (not 96), so the combined savable
figure across both parents tops out at 96 total — never 192. This is
the bug this task exists to fix: the age-4 ceiling is per CHILD, not
per PARENT pool.
"""
async def test_two_parent_couple_caps_48_each_96_combined(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
) -> 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", planning_mode="couple", 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.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
# Both parents sit on a huge untouched pool -> both should be
# clamped to the per-parent cap, never the per-child one.
fetch_a = _make_fetch(parent_a.uuid, ws.id, child.id, total=240, used=0)
fetch_b = _make_fetch(parent_b.uuid, ws.id, child.id, total=240, used=0)
await parental_leave_repo.create(fetch_a)
await parental_leave_repo.create(fetch_b)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
proj_a = await service.get_days_left_at_age_4(parent_a.uuid, child.id)
proj_b = await service.get_days_left_at_age_4(parent_b.uuid, child.id)
assert proj_a.savable_cap_at_b4 == 48
assert proj_b.savable_cap_at_b4 == 48
assert proj_a.savable_after_b4 == 48
assert proj_b.savable_after_b4 == 48
assert proj_a.expiring_at_b4 == 192 # 240 - 48
assert proj_b.expiring_at_b4 == 192
overview = await service.get_overview(ws.id, child.id)
combined_savable = sum(p.savable_after_b4 for p in overview.parent_projections)
assert combined_savable == 96 # never 192 — this is the headline regression
assert overview.combined_expiring_at_b4 == 384 # (240-48) * 2
async def test_couple_mode_with_single_parent_falls_back_to_96(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
) -> None:
"""Safety belt: `planning_mode == 'couple'` with only one member so
far (partner not added yet) must still use the per-child (96)
figure — there is no second parent to divide it with.
"""
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", planning_mode="couple", created_at=now)
)
parent = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now)
)
birth = date.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
fetch = _make_fetch(parent.uuid, ws.id, child.id, total=240, used=0)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
proj = await service.get_days_left_at_age_4(parent.uuid, child.id)
assert proj.savable_cap_at_b4 == 96
assert proj.savable_after_b4 == 96
assert proj.expiring_at_b4 == 144 # 240 - 96
class TestAgreesWithPlanProposalForTheSameCommittedState:
"""Bug A regression: the Dashboard (`AnalyticsService`) and the Plan tab's
propose-time projection (`allocation_service._Ledger.build_age4_projections`,
@@ -374,7 +484,7 @@ class TestAgreesWithPlanProposalForTheSameCommittedState:
# -- Dashboard side --
analytics = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id)