Tell parents how to save the days expiring at age 4
The dashboard stated the loss and stopped there. With Jacob at 48/48 savable
and 24 days over the cap, and Amelie at 3/48 with 45 days of headroom, those
24 days are not lost at all — transferring them before the 4th birthday saves
every one. The app never said so.
`_rule_age4_pooling_loss` already computed exactly that advice, bounded by the
FK-reported `transferable_days_left`, in finished Swedish and English. It never
ran: `age4_pooling_tip_min_child_age_years` held it back until the child turned
2. The calendar IS the plan, so gate on the plan instead — the noise case is a
newborn with nothing planned, not a young child whose parents have planned in
detail. `age4_pooling_tip_min_planned_days` (20) replaces the age gate.
Both variants drop to `info`. A projection four years out should not read as a
verdict on the user's planning; the hero carries the alarm, this card carries
the advice. The copy now leads with "plans change, this is only a projection"
and closes by pointing at keeping balances up to date, since parents will come
back and record what they actually took.
- Suppress `preserve_for_age_4` whenever `age4_pooling_loss` fires for the same
child. The pooling tip states the same loss plus the remedy and the correct
per-parent cap, so emitting both gave two contradictory "you're losing N
days" figures.
- `preserve_for_age_4` now sends `parent_cap` and its copy interpolates it. It
claimed "only 96 days can be saved" — false for a two-parent family, where
the cap is 48 each, as the Age4Bars card two rows below already showed.
- The hero grows a "How to save them" line derived from the tip itself, so the
two can never disagree on the number. Dismissing the tip retires both.
- "Försäkringskassan" in that line links to FK's BankID login carrying a `goto`
to Min föräldrapenning — the e-service showing "Dagar ni kan föra över" —
so the transfer is one click away. `translate()` returns a plain string and
word order differs between locales, so the anchor's position travels inside
the template as `{fk}` and the rendered string is split around it.
Verified in the browser: hero, link target, tip severity and the absence of
`preserve_for_age_4`, in both languages and at 390px. Note the demo seed cannot
reach this shape — it gives the surplus parent no FK snapshot at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
@@ -37,11 +37,15 @@ class LeaveRules(BaseModel):
|
||||
max_savable_days_at_age_4: int = 96 # age-4 ceiling — per CHILD (pooled across parents)
|
||||
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
|
||||
# A couple's age-4 pooling-loss tip (see
|
||||
# `app.utils.tip_predicates.age4_pooling_loss`) only fires once the
|
||||
# calendar holds at least this many planned parental-benefit days for the
|
||||
# child. The calendar IS the plan: below this the projection is mostly
|
||||
# untouched entitlement rather than a real plan, so the "loss" is an
|
||||
# artefact of not having planned yet, not something to act on. Child age is
|
||||
# deliberately NOT the gate — a family that plans in detail early should get
|
||||
# the advice early. A tunable threshold, not a legal rule.
|
||||
age4_pooling_tip_min_planned_days: int = 20
|
||||
|
||||
sgi_protected_first_months: int = 12
|
||||
sgi_min_days_per_week_after_age_1: int = 5
|
||||
|
||||
@@ -180,6 +180,7 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
|
||||
"parent_name": parent.name,
|
||||
"expiring_days": expiring_at_b4,
|
||||
"fourth_birthday": fourth_bday.isoformat(),
|
||||
"parent_cap": savable_cap,
|
||||
},
|
||||
anchor_date=fourth_bday,
|
||||
)
|
||||
@@ -196,6 +197,19 @@ async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None:
|
||||
can cause) is a legal fact about the child having two parents, purely a
|
||||
function of parent count.
|
||||
|
||||
The gate is the size of the plan, not the child's age: below
|
||||
`settings.rules.age4_pooling_tip_min_planned_days` planned
|
||||
parental-benefit days for this child, the projection this rule reasons
|
||||
over is mostly untouched entitlement rather than anything the family
|
||||
actually decided — the "loss" would be an artefact of not having planned
|
||||
yet, not something to act on. Child age is deliberately not the gate: a
|
||||
family that plans in detail early (e.g. right after birth) should get
|
||||
this advice early, not be made to wait out an arbitrary age. Once the
|
||||
calendar holds a real plan, severity is `info` rather than `warning` —
|
||||
the dashboard hero already carries the alarm about days expiring, so this
|
||||
card reads as forward-looking advice on a four-year-out projection, not a
|
||||
second verdict on the user's planning.
|
||||
|
||||
Advice order is the substance of this tip: taking more leave before the
|
||||
birthday converts entitlement into actual time at home (strictly
|
||||
better), so it is offered first. Transferring days at Försäkringskassan
|
||||
@@ -210,8 +224,9 @@ async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None:
|
||||
return None
|
||||
|
||||
child = ctx.children[0]
|
||||
if not is_child_over_age(child.birth_date, settings.rules.age4_pooling_tip_min_child_age_years):
|
||||
return None # first couple of years: too early to usefully act on this
|
||||
planned_fp_count = sum(1 for d in ctx.planned_days if d.day_type in ("sjukpenning", "lagsta"))
|
||||
if planned_fp_count < settings.rules.age4_pooling_tip_min_planned_days:
|
||||
return None # not enough of a real plan yet for this projection to mean anything
|
||||
|
||||
fourth_bday = child.birth_date + _relativedelta(years=4)
|
||||
per_parent_cap = resolve_age4_savable_cap(len(ctx.parents))
|
||||
@@ -269,7 +284,7 @@ async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None:
|
||||
transfer_days = max(transfer_days, 0)
|
||||
return Tip(
|
||||
id="age4_pooling_loss",
|
||||
severity="warning",
|
||||
severity="info",
|
||||
message_key="tips.age4_pooling_loss_exact",
|
||||
data={
|
||||
"surplus_parent": surplus_parent.name,
|
||||
@@ -285,7 +300,7 @@ async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None:
|
||||
if not partner_has_snapshot and other_parents:
|
||||
return Tip(
|
||||
id="age4_pooling_loss",
|
||||
severity="warning",
|
||||
severity="info",
|
||||
message_key="tips.age4_pooling_loss_hedged",
|
||||
data={
|
||||
"surplus_parent": surplus_parent.name,
|
||||
@@ -634,9 +649,20 @@ class TipsService:
|
||||
for ctx in child_contexts:
|
||||
child = ctx.children[0]
|
||||
results = await asyncio.gather(*(rule(ctx) for rule in child_rules))
|
||||
for tip in results:
|
||||
if tip is None:
|
||||
continue
|
||||
batch = [tip for tip in results if tip is not None]
|
||||
|
||||
# `age4_pooling_loss` is a strict superset of `preserve_for_age_4`
|
||||
# for a given child: it states the same expiring-days loss *and*
|
||||
# the remedy (take more leave, then transfer) *and* the correct
|
||||
# per-parent cap, while `preserve_for_age_4` only ever names one
|
||||
# parent's figure against a cap the reader may misread as the
|
||||
# per-child 96. Emitting both is duplicate, mutually contradictory
|
||||
# advice (two different "you're losing N days" numbers), so drop
|
||||
# the weaker one whenever the stronger one also fired this batch.
|
||||
if any(tip.id == "age4_pooling_loss" for tip in batch):
|
||||
batch = [tip for tip in batch if tip.id != "preserve_for_age_4"]
|
||||
|
||||
for tip in batch:
|
||||
tip.data = {**tip.data, "child_id": str(child.id), "child_name": child.name}
|
||||
tip.id = f"{tip.id}:{child.id}"
|
||||
tips.append(tip)
|
||||
|
||||
@@ -9,6 +9,7 @@ from uuid import UUID, uuid4
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from app.config import settings
|
||||
from app.entities.child import Child
|
||||
from app.entities.leave_wish import LeaveWish
|
||||
from app.entities.parental_leave import ParentalLeaveFetch, TierData
|
||||
@@ -274,6 +275,7 @@ class TestPreserveForAge4:
|
||||
assert tip.id == "preserve_for_age_4"
|
||||
assert tip.severity == "warning"
|
||||
assert tip.data["expiring_days"] == 124
|
||||
assert tip.data["parent_cap"] == 96
|
||||
|
||||
async def test_no_fire_when_projected_days_within_savable_limit(self):
|
||||
ws = uuid4()
|
||||
@@ -340,6 +342,7 @@ class TestPreserveForAge4:
|
||||
tip = await _rule_preserve_for_age_4(ctx)
|
||||
assert tip is not None
|
||||
assert tip.data["expiring_days"] == 172 # 220 - 48
|
||||
assert tip.data["parent_cap"] == 48
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -347,6 +350,24 @@ class TestPreserveForAge4:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_fp_planned_days(child_id: UUID, workspace_id: UUID, count: int) -> list[PlannedLeaveDay]:
|
||||
"""`count` planned FP days attributed to a parent NOT in the test's
|
||||
|
||||
`ctx.parents`, so they clear `age4_pooling_tip_min_planned_days`'s
|
||||
"is there a real plan" gate (a workspace-wide count) without being
|
||||
attributable to either tested parent and shifting their
|
||||
`planned_fp_before_b4` figures — which would silently change the
|
||||
excess/projected-left numbers the exact-figure assertions below depend
|
||||
on.
|
||||
"""
|
||||
filler_parent = _make_parent(workspace_id=workspace_id, name="Filler")
|
||||
base = date.today() - relativedelta(years=10)
|
||||
return [
|
||||
_make_fp_day(filler_parent, child_id, workspace_id, base - timedelta(days=i))
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
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).
|
||||
@@ -364,16 +385,20 @@ class TestAge4PoolingLoss:
|
||||
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
|
||||
planned = _make_fp_planned_days(
|
||||
child.id, ws, settings.rules.age4_pooling_tip_min_planned_days
|
||||
)
|
||||
ctx = _make_ctx(
|
||||
workspace_id=ws,
|
||||
children=[child],
|
||||
parents=[a, b],
|
||||
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
|
||||
planned_days=planned,
|
||||
)
|
||||
tip = await _rule_age4_pooling_loss(ctx)
|
||||
assert tip is not None
|
||||
assert tip.id == "age4_pooling_loss"
|
||||
assert tip.severity == "warning"
|
||||
assert tip.severity == "info"
|
||||
assert tip.message_key == "tips.age4_pooling_loss_exact"
|
||||
assert tip.data["surplus_parent"] == "A"
|
||||
assert tip.data["lagging_parent"] == "B"
|
||||
@@ -394,11 +419,15 @@ class TestAge4PoolingLoss:
|
||||
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)
|
||||
planned = _make_fp_planned_days(
|
||||
child.id, ws, settings.rules.age4_pooling_tip_min_planned_days
|
||||
)
|
||||
ctx = _make_ctx(
|
||||
workspace_id=ws,
|
||||
children=[child],
|
||||
parents=[a, b],
|
||||
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
|
||||
planned_days=planned,
|
||||
)
|
||||
tip = await _rule_age4_pooling_loss(ctx)
|
||||
assert tip is not None
|
||||
@@ -414,11 +443,15 @@ class TestAge4PoolingLoss:
|
||||
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
|
||||
planned = _make_fp_planned_days(
|
||||
child.id, ws, settings.rules.age4_pooling_tip_min_planned_days
|
||||
)
|
||||
ctx = _make_ctx(
|
||||
workspace_id=ws,
|
||||
children=[child],
|
||||
parents=[a, b],
|
||||
leave_by_parent={a.uuid: leave_a, b.uuid: None},
|
||||
planned_days=planned,
|
||||
)
|
||||
tip = await _rule_age4_pooling_loss(ctx)
|
||||
assert tip is not None
|
||||
@@ -435,31 +468,73 @@ class TestAge4PoolingLoss:
|
||||
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
|
||||
planned = _make_fp_planned_days(
|
||||
child.id, ws, settings.rules.age4_pooling_tip_min_planned_days
|
||||
)
|
||||
ctx = _make_ctx(
|
||||
workspace_id=ws,
|
||||
children=[child],
|
||||
parents=[a, b],
|
||||
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
|
||||
planned_days=planned,
|
||||
)
|
||||
tip = await _rule_age4_pooling_loss(ctx)
|
||||
assert tip is None
|
||||
|
||||
async def test_no_fire_for_child_under_2(self):
|
||||
async def test_no_fire_below_planned_day_threshold(self):
|
||||
"""Same 60/10 surplus shape as the headline case, but the calendar
|
||||
|
||||
holds one planned FP day short of the threshold — the rule must not
|
||||
fire on an entitlement projection that isn't backed by a real plan
|
||||
yet, regardless of how large the eventual loss would be.
|
||||
"""
|
||||
ws = uuid4()
|
||||
child = _make_child(date.today() - relativedelta(years=1))
|
||||
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
|
||||
planned = _make_fp_planned_days(
|
||||
child.id, ws, settings.rules.age4_pooling_tip_min_planned_days - 1
|
||||
)
|
||||
ctx = _make_ctx(
|
||||
workspace_id=ws,
|
||||
children=[child],
|
||||
parents=[a, b],
|
||||
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
|
||||
planned_days=planned,
|
||||
)
|
||||
tip = await _rule_age4_pooling_loss(ctx)
|
||||
assert tip is None
|
||||
|
||||
async def test_fires_for_infant_once_the_calendar_holds_a_real_plan(self):
|
||||
"""Child age is no longer the gate: an infant with a substantive
|
||||
|
||||
plan already in the calendar (>= the planned-day threshold) must get
|
||||
this advice just as readily as an older child — this is the
|
||||
regression that proves the gate moved from child age to plan size.
|
||||
"""
|
||||
ws = uuid4()
|
||||
child = _make_child(date.today() - relativedelta(months=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
|
||||
planned = _make_fp_planned_days(
|
||||
child.id, ws, settings.rules.age4_pooling_tip_min_planned_days
|
||||
)
|
||||
ctx = _make_ctx(
|
||||
workspace_id=ws,
|
||||
children=[child],
|
||||
parents=[a, b],
|
||||
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
|
||||
planned_days=planned,
|
||||
)
|
||||
tip = await _rule_age4_pooling_loss(ctx)
|
||||
assert tip is not None
|
||||
assert tip.message_key == "tips.age4_pooling_loss_exact"
|
||||
assert tip.data["transfer_days"] == 12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _rule_use_dubbeldagar_strategically
|
||||
|
||||
@@ -235,6 +235,109 @@ class TestMultiChildContexts:
|
||||
assert child_names == {"Adam", "Berit"}
|
||||
|
||||
|
||||
class TestPoolingLossSuppressesPreserveForAge4:
|
||||
"""`age4_pooling_loss` is a strict superset of `preserve_for_age_4` for a
|
||||
|
||||
given child (same expiring-days loss, plus the remedy and the correct
|
||||
per-parent cap), so `TipsService.get_tips` drops the weaker
|
||||
`preserve_for_age_4` whenever the stronger tip also fires in the same
|
||||
per-child batch — see the suppression logic in `get_tips`.
|
||||
"""
|
||||
|
||||
async def test_two_parent_surplus_headroom_suppresses_preserve_for_age_4(
|
||||
self,
|
||||
tips_service: TipsService,
|
||||
workspace_repo: SQLiteWorkspaceRepository,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
child_repo: SQLiteChildRepository,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", 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)
|
||||
)
|
||||
child = await child_repo.create(
|
||||
Child(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
name="Barn",
|
||||
birth_date=date.today().replace(year=date.today().year - 1),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
fourth_bday = child.birth_date.replace(year=child.birth_date.year + 4)
|
||||
|
||||
# A: left = 60 (cap 48 -> excess 12). B: left = 10 (cap 48 -> headroom
|
||||
# 38). Pooled the family could keep min(70, 96) = 70; capped per
|
||||
# parent it only keeps 48 + 10 = 58 -> family_loss = 12.
|
||||
await parental_leave_repo.create(_make_fetch(parent_a.uuid, ws.id, child.id, used=180))
|
||||
await parental_leave_repo.create(_make_fetch(parent_b.uuid, ws.id, child.id, used=230))
|
||||
|
||||
# A substantive plan (>= the planned-day gate), dated AFTER the 4th
|
||||
# birthday so it doesn't shift `planned_fp_before_b4` and change the
|
||||
# expiring/excess figures computed above.
|
||||
for i in range(20):
|
||||
await planned_leave_repo.create(
|
||||
_make_planned_day(
|
||||
ws.id, child.id, parent_a.uuid, fourth_bday + timedelta(days=i + 30)
|
||||
)
|
||||
)
|
||||
|
||||
tips = await tips_service.get_tips(ws.id)
|
||||
|
||||
pooling_tips = [t for t in tips if t.id == f"age4_pooling_loss:{child.id}"]
|
||||
assert len(pooling_tips) == 1, "expected age4_pooling_loss to fire for this shape"
|
||||
|
||||
preserve_tips = [t for t in tips if t.id.startswith("preserve_for_age_4:")]
|
||||
assert preserve_tips == [], (
|
||||
"preserve_for_age_4 must be suppressed when age4_pooling_loss fires "
|
||||
f"for the same child, got {[t.id for t in preserve_tips]}"
|
||||
)
|
||||
|
||||
async def test_single_parent_over_cap_keeps_preserve_for_age_4(
|
||||
self,
|
||||
tips_service: TipsService,
|
||||
workspace_repo: SQLiteWorkspaceRepository,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
child_repo: SQLiteChildRepository,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
) -> None:
|
||||
"""A single-parent workspace never has 2+ parents, so
|
||||
|
||||
`age4_pooling_loss` never even runs (it early-returns on
|
||||
`len(ctx.parents) < 2`) — nothing suppresses `preserve_for_age_4`.
|
||||
"""
|
||||
now = datetime.now()
|
||||
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
||||
parent = await personal_data_repo.create(
|
||||
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Solo", created_at=now)
|
||||
)
|
||||
child = await child_repo.create(
|
||||
Child(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
name="Barn",
|
||||
birth_date=date.today().replace(year=date.today().year - 1),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
# total=240, used=20 -> projected_left=220; single-parent cap=96 ->
|
||||
# expiring=124 > 0.
|
||||
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id, used=20))
|
||||
|
||||
tips = await tips_service.get_tips(ws.id)
|
||||
|
||||
preserve_tips = [t for t in tips if t.id == f"preserve_for_age_4:{child.id}"]
|
||||
assert len(preserve_tips) == 1
|
||||
assert preserve_tips[0].data["expiring_days"] == 124
|
||||
assert not any(t.id.startswith("age4_pooling_loss:") for t in tips)
|
||||
|
||||
|
||||
class TestDismissals:
|
||||
async def test_dismiss_hides_tip_until_data_changes(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user