From c15cd44cb4c8488e74d7980412ab0014024f2159 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Thu, 17 Sep 2026 22:31:37 +0200 Subject: [PATCH] Warn when saving unevenly costs the family days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/config.py | 6 + backend/app/utils/tip_predicates.py | 49 ++++++ .../unit/test_services/test_tips_service.py | 145 +++++++++++++++++- .../src/components/inputs/TipCard.test.tsx | 2 + frontend/src/components/inputs/TipCard.tsx | 8 + frontend/src/i18n/en/tips.ts | 6 + frontend/src/i18n/sv/tips.ts | 6 + 7 files changed, 221 insertions(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 1ed8951..c281486 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/utils/tip_predicates.py b/backend/app/utils/tip_predicates.py index dd7d4a2..0f7c872 100644 --- a/backend/app/utils/tip_predicates.py +++ b/backend/app/utils/tip_predicates.py @@ -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() diff --git a/backend/tests/unit/test_services/test_tips_service.py b/backend/tests/unit/test_services/test_tips_service.py index d585739..6724212 100644 --- a/backend/tests/unit/test_services/test_tips_service.py +++ b/backend/tests/unit/test_services/test_tips_service.py @@ -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 # --------------------------------------------------------------------------- diff --git a/frontend/src/components/inputs/TipCard.test.tsx b/frontend/src/components/inputs/TipCard.test.tsx index 4b6007d..2a1afde 100644 --- a/frontend/src/components/inputs/TipCard.test.tsx +++ b/frontend/src/components/inputs/TipCard.test.tsx @@ -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', diff --git a/frontend/src/components/inputs/TipCard.tsx b/frontend/src/components/inputs/TipCard.tsx index c3126a8..8973b87 100644 --- a/frontend/src/components/inputs/TipCard.tsx +++ b/frontend/src/components/inputs/TipCard.tsx @@ -30,6 +30,14 @@ const TIP_TRANSLATION_KEYS: Record