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:
2026-09-20 09:51:10 +02:00
parent 97a7200c1a
commit d5f33e8c7f
14 changed files with 495 additions and 27 deletions
+33 -7
View File
@@ -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)