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:
@@ -41,6 +41,12 @@ class LeaveRules(BaseModel):
|
|||||||
max_savable_days_per_parent_at_age_4: int = 48
|
max_savable_days_per_parent_at_age_4: int = 48
|
||||||
hard_expiry_age_years: int = 12
|
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_protected_first_months: int = 12
|
||||||
sgi_min_days_per_week_after_age_1: int = 5
|
sgi_min_days_per_week_after_age_1: int = 5
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ supporting value. Keeping them pure makes them trivial to unit-test.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
@@ -69,6 +71,35 @@ def days_left_at_age_4_projection(
|
|||||||
return projected_left, expiring
|
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:
|
def is_child_under_age(birth_date: date, months: int) -> bool:
|
||||||
"""True if the child's age in months is less than `months`."""
|
"""True if the child's age in months is less than `months`."""
|
||||||
return date.today() < birth_date + relativedelta(months=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.
|
expiry logic when it has the full vacation history per year.
|
||||||
"""
|
"""
|
||||||
return vacation_saved_in > 0 and vacation_total > threshold_remaining_days
|
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.entities.planned_leave import PlannedLeaveDay
|
||||||
from app.services.tips_service import (
|
from app.services.tips_service import (
|
||||||
TipsContext,
|
TipsContext,
|
||||||
|
_rule_age4_pooling_loss,
|
||||||
_rule_balance_between_parents,
|
_rule_balance_between_parents,
|
||||||
_rule_lagsta_niva_coverage,
|
_rule_lagsta_niva_coverage,
|
||||||
_rule_long_break_parent_swap,
|
_rule_long_break_parent_swap,
|
||||||
@@ -67,6 +68,7 @@ def _make_leave(
|
|||||||
used: int = 100,
|
used: int = 100,
|
||||||
sjukpenning_left: int = 200,
|
sjukpenning_left: int = 200,
|
||||||
lagsta_left: int = 60,
|
lagsta_left: int = 60,
|
||||||
|
transferable_days_left: int = 50,
|
||||||
) -> ParentalLeaveFetch:
|
) -> ParentalLeaveFetch:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
tier = TierData(
|
tier = TierData(
|
||||||
@@ -89,7 +91,7 @@ def _make_leave(
|
|||||||
days_left=total - used,
|
days_left=total - used,
|
||||||
reserved_days_total=90,
|
reserved_days_total=90,
|
||||||
reserved_days_left=90,
|
reserved_days_left=90,
|
||||||
transferable_days_left=50,
|
transferable_days_left=transferable_days_left,
|
||||||
dubbeldagar_left=60,
|
dubbeldagar_left=60,
|
||||||
tier_data=tier,
|
tier_data=tier,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
@@ -345,6 +347,147 @@ class TestPreserveForAge4:
|
|||||||
assert tip.data["expiring_days"] == 172 # 220 - 48
|
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
|
# _rule_use_dubbeldagar_strategically
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ describe('TipCard', () => {
|
|||||||
'tips.use_vacation_before_expiry',
|
'tips.use_vacation_before_expiry',
|
||||||
'tips.balance_between_parents',
|
'tips.balance_between_parents',
|
||||||
'tips.preserve_for_age_4',
|
'tips.preserve_for_age_4',
|
||||||
|
'tips.age4_pooling_loss_exact',
|
||||||
|
'tips.age4_pooling_loss_hedged',
|
||||||
'tips.use_dubbeldagar_strategically',
|
'tips.use_dubbeldagar_strategically',
|
||||||
'tips.lagsta_niva_coverage',
|
'tips.lagsta_niva_coverage',
|
||||||
'tips.stretch_time_at_home',
|
'tips.stretch_time_at_home',
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ const TIP_TRANSLATION_KEYS: Record<string, { title: TranslationKey; body: Transl
|
|||||||
title: 'tip.preserveForAge4.title',
|
title: 'tip.preserveForAge4.title',
|
||||||
body: 'tip.preserveForAge4.body',
|
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': {
|
'tips.use_dubbeldagar_strategically': {
|
||||||
title: 'tip.useDubbeldagarStrategically.title',
|
title: 'tip.useDubbeldagarStrategically.title',
|
||||||
body: 'tip.useDubbeldagarStrategically.body',
|
body: 'tip.useDubbeldagarStrategically.body',
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
export const tips = {
|
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.aria': 'Tip: {title}',
|
||||||
'tip.balanceBetweenParents.body': '{leading_parent} has taken {gap_days} more days than {lagging_parent}. Consider evening out the split.',
|
'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',
|
'tip.balanceBetweenParents.title': 'Imbalance between parents',
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
export const tips = {
|
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.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.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',
|
'tip.balanceBetweenParents.title': 'Obalans mellan föräldrarna',
|
||||||
|
|||||||
Reference in New Issue
Block a user