Warn when saving unevenly costs the family days

Only ninety-six days survive a child's fourth birthday, and with two parents
that is forty-eight each rather than a shared pool. A parent sitting on sixty
days while the other has ten therefore forfeits twelve that the family could
have kept, and nothing said so.

The advice leads with taking the days as leave before the birthday, which
turns them into actual time at home, and offers transferring only as the
fallback that merely preserves them. The transferable figure is bounded by
what Försäkringskassan actually reports as transferable, since the reserved
days cannot move, and the copy says plainly that a transfer happens there and
not in this app. Without the partner's figures the tip hedges rather than
inventing a number.
This commit is contained in:
2026-09-17 22:31:37 +02:00
parent a4fce16dd1
commit c15cd44cb4
7 changed files with 221 additions and 1 deletions
+6
View File
@@ -41,6 +41,12 @@ class LeaveRules(BaseModel):
max_savable_days_per_parent_at_age_4: int = 48
hard_expiry_age_years: int = 12
# A couple's per-parent age-4 pooling-loss tip (see
# `app.utils.tip_predicates.age4_pooling_loss`) only starts warning once
# the child has turned this many years old — the first couple of years
# are too early to usefully act on, so don't nag before then.
age4_pooling_tip_min_child_age_years: int = 2
sgi_protected_first_months: int = 12
sgi_min_days_per_week_after_age_1: int = 5
+49
View File
@@ -7,6 +7,8 @@ supporting value. Keeping them pure makes them trivial to unit-test.
from __future__ import annotations
import hashlib
import json
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
@@ -69,6 +71,35 @@ def days_left_at_age_4_projection(
return projected_left, expiring
def age4_pooling_loss(
parent_projected_lefts: list[int],
per_parent_cap: int,
per_child_cap: int,
) -> int:
"""Days a couple loses because the age-4 cap applies per parent, not pooled.
Each parent's own projected balance at the child's 4th birthday is capped
individually at `per_parent_cap` (see `resolve_age4_savable_cap`) before
summing, even though the family's shared ceiling is `per_child_cap`. When
one parent is over their own cap while another still has headroom under
it, the family ends up keeping less than `per_child_cap` would otherwise
allow — e.g. 60 and 10 days left (cap 48, ceiling 96): the family could
keep 70 pooled but only keeps 48 + 10 = 58, losing 12.
A parent with no known projection should simply be left out of
`parent_projected_lefts` — omitting them is the conservative treatment on
both sides of the comparison (as if their balance were 0), so a caller
missing one parent's data still gets a safe (never-overstated) answer.
Returns the shortfall, 0 when nothing is lost.
"""
if not parent_projected_lefts:
return 0
reachable_pooled = min(sum(parent_projected_lefts), per_child_cap)
reachable_per_parent = sum(min(left, per_parent_cap) for left in parent_projected_lefts)
return max(reachable_pooled - reachable_per_parent, 0)
def is_child_under_age(birth_date: date, months: int) -> bool:
"""True if the child's age in months is less than `months`."""
return date.today() < birth_date + relativedelta(months=months)
@@ -170,3 +201,21 @@ def has_vacation_near_expiry(
expiry logic when it has the full vacation history per year.
"""
return vacation_saved_in > 0 and vacation_total > threshold_remaining_days
def hash_tip_data(data: dict[str, object]) -> str:
"""Stable fingerprint of a tip's `data` bag.
Used to decide whether a previously-dismissed tip's underlying situation
has changed materially: if a rule fires again under the same `Tip.id` but
with different `data` (a bigger `expiring_days` count, a moved
`fourth_birthday`, a new `gap_days`, ...), the old dismissal describes a
fact that's no longer true and must not silence the new one. See
`app.entities.tip_dismissal.TipDismissal` and
`app.services.tips_service.TipsService._drop_stale_dismissed`.
`data` only ever holds JSON scalars (see `app.models.tips.TipDataValue`),
so `sort_keys=True` alone gives a stable, order-independent digest.
"""
canonical = json.dumps(data, sort_keys=True, default=str)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
@@ -16,6 +16,7 @@ from app.entities.personal_data import PersonalData
from app.entities.planned_leave import PlannedLeaveDay
from app.services.tips_service import (
TipsContext,
_rule_age4_pooling_loss,
_rule_balance_between_parents,
_rule_lagsta_niva_coverage,
_rule_long_break_parent_swap,
@@ -67,6 +68,7 @@ def _make_leave(
used: int = 100,
sjukpenning_left: int = 200,
lagsta_left: int = 60,
transferable_days_left: int = 50,
) -> ParentalLeaveFetch:
now = datetime.now()
tier = TierData(
@@ -89,7 +91,7 @@ def _make_leave(
days_left=total - used,
reserved_days_total=90,
reserved_days_left=90,
transferable_days_left=50,
transferable_days_left=transferable_days_left,
dubbeldagar_left=60,
tier_data=tier,
created_at=now,
@@ -345,6 +347,147 @@ class TestPreserveForAge4:
assert tip.data["expiring_days"] == 172 # 220 - 48
# ---------------------------------------------------------------------------
# _rule_age4_pooling_loss
# ---------------------------------------------------------------------------
class TestAge4PoolingLoss:
async def test_fires_for_60_10_couple_case_with_exact_figures(self):
"""The headline scenario: A has 60 days left, B has 10 (cap 48 each).
Pooled, the family could keep min(70, 96) = 70; capped per parent it
only keeps 48 + 10 = 58, losing 12. A's own excess over the cap is
60 - 48 = 12, and B has 48 - 10 = 38 days of headroom — so the
transferable figure is min(excess=12, headroom=38,
transferable_days_left) = 12 (transferable_days_left defaults to 50,
well above 12).
"""
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=3))
a = _make_parent(workspace_id=ws, name="A")
b = _make_parent(workspace_id=ws, name="B")
leave_a = _make_leave(a, child.id, total=240, used=180) # left = 60
leave_b = _make_leave(b, child.id, total=240, used=230) # left = 10
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
planning_mode="couple",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is not None
assert tip.id == "age4_pooling_loss"
assert tip.severity == "warning"
assert tip.message_key == "tips.age4_pooling_loss_exact"
assert tip.data["surplus_parent"] == "A"
assert tip.data["lagging_parent"] == "B"
assert tip.data["excess_days"] == 12
assert tip.data["transfer_days"] == 12
assert tip.data["family_loss_days"] == 12
assert tip.data["parent_cap"] == 48
async def test_transfer_figure_bounded_by_transferable_days_left(self):
"""Same 60/10 shape, but A's FK snapshot only reports 5 transferable
days (most of A's balance is the non-transferable reserved slice) —
the recommended transfer must not exceed that, even though the raw
excess (12) and B's headroom (38) would allow more.
"""
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=3))
a = _make_parent(workspace_id=ws, name="A")
b = _make_parent(workspace_id=ws, name="B")
leave_a = _make_leave(a, child.id, total=240, used=180, transferable_days_left=5)
leave_b = _make_leave(b, child.id, total=240, used=230)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
planning_mode="couple",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is not None
assert tip.data["transfer_days"] == 5
async def test_hedged_when_partner_has_no_snapshot(self):
"""B exists in the workspace but has never entered/fetched FK data —
the tip must still warn (A is unambiguously over the cap) but must
not invent a transfer figure it can't back with data.
"""
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=3))
a = _make_parent(workspace_id=ws, name="A")
b = _make_parent(workspace_id=ws, name="B")
leave_a = _make_leave(a, child.id, total=240, used=180) # left = 60
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: None},
planning_mode="couple",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is not None
assert tip.message_key == "tips.age4_pooling_loss_hedged"
assert tip.data["surplus_parent"] == "A"
assert tip.data["excess_days"] == 12
assert "transfer_days" not in tip.data
assert "lagging_parent" not in tip.data
async def test_no_fire_when_both_parents_under_cap(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=3))
a = _make_parent(workspace_id=ws, name="A")
b = _make_parent(workspace_id=ws, name="B")
leave_a = _make_leave(a, child.id, total=240, used=200) # left = 40
leave_b = _make_leave(b, child.id, total=240, used=210) # left = 30
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
planning_mode="couple",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is None
async def test_no_fire_for_child_under_2(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
a = _make_parent(workspace_id=ws, name="A")
b = _make_parent(workspace_id=ws, name="B")
leave_a = _make_leave(a, child.id, total=240, used=180) # left = 60
leave_b = _make_leave(b, child.id, total=240, used=230) # left = 10
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
planning_mode="couple",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is None
async def test_no_fire_in_solo_mode(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=3))
a = _make_parent(workspace_id=ws, name="A")
b = _make_parent(workspace_id=ws, name="B")
leave_a = _make_leave(a, child.id, total=240, used=180) # left = 60
leave_b = _make_leave(b, child.id, total=240, used=230) # left = 10
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
planning_mode="solo",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_use_dubbeldagar_strategically
# ---------------------------------------------------------------------------
@@ -87,6 +87,8 @@ describe('TipCard', () => {
'tips.use_vacation_before_expiry',
'tips.balance_between_parents',
'tips.preserve_for_age_4',
'tips.age4_pooling_loss_exact',
'tips.age4_pooling_loss_hedged',
'tips.use_dubbeldagar_strategically',
'tips.lagsta_niva_coverage',
'tips.stretch_time_at_home',
@@ -30,6 +30,14 @@ const TIP_TRANSLATION_KEYS: Record<string, { title: TranslationKey; body: Transl
title: 'tip.preserveForAge4.title',
body: 'tip.preserveForAge4.body',
},
'tips.age4_pooling_loss_exact': {
title: 'tip.age4PoolingLossExact.title',
body: 'tip.age4PoolingLossExact.body',
},
'tips.age4_pooling_loss_hedged': {
title: 'tip.age4PoolingLossHedged.title',
body: 'tip.age4PoolingLossHedged.body',
},
'tips.use_dubbeldagar_strategically': {
title: 'tip.useDubbeldagarStrategically.title',
body: 'tip.useDubbeldagarStrategically.body',
+6
View File
@@ -1,4 +1,10 @@
export const tips = {
'tip.age4PoolingLossExact.body':
'{surplus_parent} has {excess_days} more days left than may be saved per parent at the child\'s 4th birthday ({fourth_birthday}) — otherwise the family loses {family_loss_days} days. Taking that leave before then is best, since it turns entitlement into actual time at home. If that\'s not possible, you can transfer up to {transfer_days} days to {lagging_parent} at Försäkringskassan instead — that saves the days but doesn\'t add any extra time off, and the transfer itself happens at Försäkringskassan, not in this app.',
'tip.age4PoolingLossExact.title': 'Uneven saving costs the family days',
'tip.age4PoolingLossHedged.body':
'{surplus_parent} has {excess_days} more days left than may be saved per parent at the child\'s 4th birthday ({fourth_birthday}). Taking that leave before then is best, since it turns entitlement into actual time at home. If you both have days left, you can otherwise transfer days between you at Försäkringskassan — that saves the days but doesn\'t add any extra time off, and the transfer itself happens at Försäkringskassan, not in this app.',
'tip.age4PoolingLossHedged.title': 'Uneven saving may cost the family days',
'tip.aria': 'Tip: {title}',
'tip.balanceBetweenParents.body': '{leading_parent} has taken {gap_days} more days than {lagging_parent}. Consider evening out the split.',
'tip.balanceBetweenParents.title': 'Imbalance between parents',
+6
View File
@@ -1,4 +1,10 @@
export const tips = {
'tip.age4PoolingLossExact.body':
'{surplus_parent} har {excess_days} fler dagar kvar än vad som får sparas per förälder vid 4-årsdagen ({fourth_birthday}) — annars tappar familjen {family_loss_days} dagar. Bäst är att ta ut dagarna som ledighet innan dess, det ger faktisk tid hemma. Går inte det kan ni överföra upp till {transfer_days} dagar till {lagging_parent} hos Försäkringskassan istället — det räddar dagarna men ger ingen extra ledighet, och överföringen sker hos Försäkringskassan, inte i appen.',
'tip.age4PoolingLossExact.title': 'Ojämnt sparande kostar familjen dagar',
'tip.age4PoolingLossHedged.body':
'{surplus_parent} har {excess_days} fler dagar kvar än vad som får sparas per förälder vid 4-årsdagen ({fourth_birthday}). Bäst är att ta ut dagarna som ledighet innan dess, det ger faktisk tid hemma. Om ni båda har dagar kvar kan ni annars överföra dagar mellan er hos Försäkringskassan — det räddar dagarna men ger ingen extra ledighet, och överföringen sker hos Försäkringskassan, inte i appen.',
'tip.age4PoolingLossHedged.title': 'Ojämnt sparande kan kosta familjen dagar',
'tip.aria': 'Tips: {title}',
'tip.balanceBetweenParents.body': '{leading_parent} har tagit ut {gap_days} fler dagar än {lagging_parent}. Fundera på att jämna ut fördelningen.',
'tip.balanceBetweenParents.title': 'Obalans mellan föräldrarna',