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