From d5f33e8c7fee35ab22289557948611a5266fa023 Mon Sep 17 00:00:00 2001
From: Jacob Hagstedt
Date: Sun, 20 Sep 2026 09:51:10 +0200
Subject: [PATCH] Tell parents how to save the days expiring at age 4
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
---
backend/app/config.py | 14 +-
backend/app/services/tips_service.py | 40 +++++-
.../unit/test_services/test_tips_service.py | 81 +++++++++++-
.../test_tips_service_context.py | 103 +++++++++++++++
.../src/components/dashboard/Hero.test.tsx | 121 +++++++++++++++++-
frontend/src/components/dashboard/Hero.tsx | 47 ++++++-
frontend/src/i18n/en/dashboard.ts | 3 +
frontend/src/i18n/en/tips.ts | 10 +-
frontend/src/i18n/sv/dashboard.ts | 3 +
frontend/src/i18n/sv/tips.ts | 10 +-
frontend/src/pages/Dashboard.test.tsx | 38 ++++++
frontend/src/pages/Dashboard.tsx | 26 ++++
frontend/src/styles/components.css | 12 ++
frontend/src/utils/constants.ts | 14 ++
14 files changed, 495 insertions(+), 27 deletions(-)
diff --git a/backend/app/config.py b/backend/app/config.py
index 2d142ad..ecd1acb 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -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
diff --git a/backend/app/services/tips_service.py b/backend/app/services/tips_service.py
index 46cf937..af40800 100644
--- a/backend/app/services/tips_service.py
+++ b/backend/app/services/tips_service.py
@@ -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)
diff --git a/backend/tests/unit/test_services/test_tips_service.py b/backend/tests/unit/test_services/test_tips_service.py
index f74fdd7..6e3581a 100644
--- a/backend/tests/unit/test_services/test_tips_service.py
+++ b/backend/tests/unit/test_services/test_tips_service.py
@@ -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
diff --git a/backend/tests/unit/test_services/test_tips_service_context.py b/backend/tests/unit/test_services/test_tips_service_context.py
index 1923fd9..ef497b3 100644
--- a/backend/tests/unit/test_services/test_tips_service_context.py
+++ b/backend/tests/unit/test_services/test_tips_service_context.py
@@ -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,
diff --git a/frontend/src/components/dashboard/Hero.test.tsx b/frontend/src/components/dashboard/Hero.test.tsx
index 3a2a6ba..bc9bb52 100644
--- a/frontend/src/components/dashboard/Hero.test.tsx
+++ b/frontend/src/components/dashboard/Hero.test.tsx
@@ -1,6 +1,8 @@
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Hero } from './Hero'
+import { FK_TRANSFER_LOGIN_URL } from '@/utils/constants'
+import { LanguageProvider } from '@/i18n'
describe('Hero', () => {
it('uses the forward-looking framing when no plan exists yet', () => {
@@ -35,6 +37,123 @@ describe('Hero', () => {
expect(hero).not.toHaveAttribute('role', 'alert')
})
+ it('renders the remedy line when a plan exists, days are at risk, and a remedy is supplied', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('dashboard-hero-remedy')).toHaveTextContent(
+ 'Så räddar ni dem Ta ut dem som ledighet innan dess, eller för över upp till 24 dagar från Jacob till Amelie hos Försäkringskassan före 1 juni 2029.',
+ )
+ })
+
+ it('omits the remedy line when remedy is null', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByTestId('dashboard-hero-remedy')).not.toBeInTheDocument()
+ })
+
+ it('omits the remedy line when remedy is omitted entirely', () => {
+ render()
+ expect(screen.queryByTestId('dashboard-hero-remedy')).not.toBeInTheDocument()
+ })
+
+ it('omits the remedy line when hasPlan is false, even with a remedy supplied', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByTestId('dashboard-hero-remedy')).not.toBeInTheDocument()
+ })
+
+ it('omits the remedy line when daysAtRisk is 0, even with a remedy supplied', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByTestId('dashboard-hero-remedy')).not.toBeInTheDocument()
+ })
+
+ it('renders the remedy line with a real link to Försäkringskassan (Swedish, the default test locale)', () => {
+ render(
+ ,
+ )
+ const remedy = screen.getByTestId('dashboard-hero-remedy')
+ expect(remedy).toHaveTextContent(
+ 'Så räddar ni dem Ta ut dem som ledighet innan dess, eller för över upp till 24 dagar från Jacob till Amelie hos Försäkringskassan före 1 juni 2029.',
+ )
+ const link = screen.getByRole('link', { name: 'Försäkringskassan' })
+ expect(link).toHaveAttribute('href', FK_TRANSFER_LOGIN_URL)
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
+ it('renders the remedy line with a real link to Försäkringskassan in English', () => {
+ Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
+ vi.stubGlobal('localStorage', {
+ getItem: () => null,
+ setItem: () => undefined,
+ removeItem: () => undefined,
+ })
+
+ try {
+ render(
+
+
+ ,
+ )
+ const remedy = screen.getByTestId('dashboard-hero-remedy')
+ expect(remedy).toHaveTextContent(
+ 'How to save them Take them as leave before then, or transfer up to 24 days from Jacob to Amelie at Försäkringskassan before June 1, 2029.',
+ )
+ const link = screen.getByRole('link', { name: 'Försäkringskassan' })
+ expect(link).toHaveAttribute('href', FK_TRANSFER_LOGIN_URL)
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer')
+ } finally {
+ Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
+ vi.unstubAllGlobals()
+ }
+ })
+
+ it('omits the Försäkringskassan link when remedy is absent', () => {
+ render()
+ expect(screen.queryByRole('link', { name: 'Försäkringskassan' })).not.toBeInTheDocument()
+ })
+
it('renders the same daysAtRisk figure in both framings without recomputing it', () => {
const { rerender } = render(
,
diff --git a/frontend/src/components/dashboard/Hero.tsx b/frontend/src/components/dashboard/Hero.tsx
index f94e59a..8966caf 100644
--- a/frontend/src/components/dashboard/Hero.tsx
+++ b/frontend/src/components/dashboard/Hero.tsx
@@ -1,6 +1,15 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
import { formatDateForDisplay } from '@/utils/dates'
+import { FK_TRANSFER_LOGIN_URL } from '@/utils/constants'
+
+// Never a literal string a real sentence could contain — {fk} is
+// interpolated with this, then the resulting string is split back apart, so
+// the anchor lands exactly where the locale template puts "Försäkringskassan"
+// regardless of Swedish/English word order. `translate()` only ever returns
+// a plain string, so this is the only way to get a real into the middle
+// of a translated sentence.
+const FK_PLACEHOLDER = '\u0000fk\u0000'
export interface HeroProps {
/** True once at least one day has actually been planned for this child. */
@@ -13,6 +22,9 @@ export interface HeroProps {
childName: string
/** ISO-8601 date. */
fourthBirthday: string
+ /** Present only when the age-4 loss is avoidable by transferring days
+ * between the parents. Derived from the `age4_pooling_loss` tip. */
+ remedy?: { transferDays: number; fromParent: string; toParent: string } | null
}
/**
@@ -23,7 +35,7 @@ export interface HeroProps {
* plan exists and nothing is actually at risk, and a plain warning once a
* plan exists and some days are at risk.
*/
-export function Hero({ hasPlan, daysLeft, daysAtRisk, childName, fourthBirthday }: HeroProps): ReactNode {
+export function Hero({ hasPlan, daysLeft, daysAtRisk, childName, fourthBirthday, remedy }: HeroProps): ReactNode {
const { t, locale } = useTranslation()
const noneAtRisk = daysAtRisk === 0
@@ -41,6 +53,22 @@ export function Hero({ hasPlan, daysLeft, daysAtRisk, childName, fourthBirthday
const modifier = hasPlan ? (noneAtRisk ? 'dashboard-hero--reassure' : 'dashboard-hero--warning') : 'dashboard-hero--motivate'
+ // Interpolate a sentinel in place of the agency name, then split the
+ // resulting string around it, so the below lands exactly where the
+ // locale template places "Försäkringskassan" — word order differs between
+ // Swedish and English, so the placeholder has to travel inside the
+ // translated template rather than being concatenated around it.
+ const remedyText = remedy
+ ? t('dashboard.hero.remedyMessage', {
+ days: remedy.transferDays,
+ fromParent: remedy.fromParent,
+ toParent: remedy.toParent,
+ date,
+ fk: FK_PLACEHOLDER,
+ })
+ : undefined
+ const [remedyBeforeLink, remedyAfterLink] = remedyText ? remedyText.split(FK_PLACEHOLDER) : [undefined, undefined]
+
return (
+ {hasPlan && !noneAtRisk && remedy && (
+
+ {t('dashboard.hero.remedyLabel')}{' '}
+ {remedyBeforeLink}
+ {remedyAfterLink !== undefined ? (
+
+ {t('dashboard.hero.fkLinkText')}
+
+ ) : null}
+ {remedyAfterLink}
+
+ )}
)
}
diff --git a/frontend/src/i18n/en/dashboard.ts b/frontend/src/i18n/en/dashboard.ts
index 820a3e3..05670e3 100644
--- a/frontend/src/i18n/en/dashboard.ts
+++ b/frontend/src/i18n/en/dashboard.ts
@@ -15,9 +15,12 @@ export const dashboard = {
'dashboard.figureExpiring': 'Expiring at age 4',
'dashboard.figureLeft': 'Days left',
'dashboard.figureUsed': 'Used',
+ 'dashboard.hero.fkLinkText': 'Försäkringskassan',
'dashboard.hero.hasPlanMessage': '{atRisk} days are at risk of disappearing when {childName} turns 4 ({date}).',
'dashboard.hero.noneAtRiskMessage': 'No days will be lost — your plan uses them all before {childName} turns 4 ({date}).',
'dashboard.hero.noPlanMessage': 'You have {days} days to plan. Without a plan, {atRisk} of them will disappear when {childName} turns 4.',
+ 'dashboard.hero.remedyLabel': 'How to save them',
+ 'dashboard.hero.remedyMessage': 'Take them as leave before then, or transfer up to {days} days from {fromParent} to {toParent} at {fk} before {date}.',
'dashboard.leaveOverviewTitle': 'Until age 4',
'dashboard.moreDetailsSummary': 'Details',
'dashboard.overviewTitle': 'Overview',
diff --git a/frontend/src/i18n/en/tips.ts b/frontend/src/i18n/en/tips.ts
index 2a4ef3b..459dbcf 100644
--- a/frontend/src/i18n/en/tips.ts
+++ b/frontend/src/i18n/en/tips.ts
@@ -1,10 +1,10 @@
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',
+ 'Plans change, and this is only a projection from the calendar as it stands today. If it holds, {surplus_parent} ends up with {excess_days} more days left than may be saved per parent at the child\'s 4th birthday ({fourth_birthday}) — the family would lose {family_loss_days} days. Taking that leave before then is best, since it turns entitlement into actual time at home. Otherwise you can transfer up to {transfer_days} days to {lagging_parent} 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. Keep your balances here up to date as you go, so this stays based on what you actually took.',
+ 'tip.age4PoolingLossExact.title': 'Days you can still save by transferring',
'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',
+ 'Plans change, and this is only a projection from the calendar as it stands today. If it holds, {surplus_parent} ends up with {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. Keep your balances here up to date as you go, so this stays based on what you actually took.',
+ 'tip.age4PoolingLossHedged.title': 'Days you may be able to save by transferring',
'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.title': 'Imbalance between parents',
@@ -13,7 +13,7 @@ export const tips = {
'tip.lagstaNivaCoverage.title': 'Cover the gap with lägstanivå',
'tip.longBreakParentSwap.body': '{parent_name} is covering less than half of "{break_name}" ({start_date}–{end_date}). Let {other_parent_name} take part of the period.',
'tip.longBreakParentSwap.title': 'Consider alternating',
- 'tip.preserveForAge4.body': '{parent_name} risks losing {expiring_days} days when the child turns 4 ({fourth_birthday}). Only 96 days can be saved after that.',
+ 'tip.preserveForAge4.body': '{parent_name} risks losing {expiring_days} days when the child turns 4 ({fourth_birthday}). Only {parent_cap} days can be saved per parent after that.',
'tip.preserveForAge4.title': 'Days at risk of being lost at age 4',
'tip.reserveForPreschoolPlanningDays.body': '{parent_name} has {days_left} days left, which barely covers the preschool\'s {buffer_days} planning days. Reserve days ahead of time.',
'tip.reserveForPreschoolPlanningDays.title': 'Reserve days for preschool planning days',
diff --git a/frontend/src/i18n/sv/dashboard.ts b/frontend/src/i18n/sv/dashboard.ts
index 4598aaf..f0aede3 100644
--- a/frontend/src/i18n/sv/dashboard.ts
+++ b/frontend/src/i18n/sv/dashboard.ts
@@ -15,9 +15,12 @@ export const dashboard = {
'dashboard.figureExpiring': 'Förfaller vid 4 år',
'dashboard.figureLeft': 'Dagar kvar',
'dashboard.figureUsed': 'Använda',
+ 'dashboard.hero.fkLinkText': 'Försäkringskassan',
'dashboard.hero.hasPlanMessage': '{atRisk} dagar riskerar att försvinna när {childName} fyller 4 ({date}).',
'dashboard.hero.noneAtRiskMessage': 'Inga dagar går förlorade — er plan hinner använda alla innan {childName} fyller 4 ({date}).',
'dashboard.hero.noPlanMessage': 'Du har {days} dagar att planera. Utan en plan försvinner {atRisk} av dem när {childName} fyller 4.',
+ 'dashboard.hero.remedyLabel': 'Så räddar ni dem',
+ 'dashboard.hero.remedyMessage': 'Ta ut dem som ledighet innan dess, eller för över upp till {days} dagar från {fromParent} till {toParent} hos {fk} före {date}.',
'dashboard.leaveOverviewTitle': 'Fram till 4 år',
'dashboard.moreDetailsSummary': 'Detaljer',
'dashboard.overviewTitle': 'Översikt',
diff --git a/frontend/src/i18n/sv/tips.ts b/frontend/src/i18n/sv/tips.ts
index 934f9af..8530088 100644
--- a/frontend/src/i18n/sv/tips.ts
+++ b/frontend/src/i18n/sv/tips.ts
@@ -1,10 +1,10 @@
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',
+ 'Planer ändras, och det här är bara en prognos utifrån kalendern som den ser ut i dag. Om den håller hamnar {surplus_parent} med {excess_days} fler dagar kvar än vad som får sparas per förälder vid 4-årsdagen ({fourth_birthday}) — familjen skulle då tappa {family_loss_days} dagar. Bäst är att ta ut dagarna som ledighet innan dess, det ger faktisk tid hemma. Annars kan ni föra över upp till {transfer_days} dagar till {lagging_parent} hos Försäkringskassan — det räddar dagarna men ger ingen extra ledighet, och överföringen sker hos Försäkringskassan, inte i appen. Håll saldona här uppdaterade efter hand, så att prognosen bygger på vad ni faktiskt tog ut.',
+ 'tip.age4PoolingLossExact.title': 'Dagar ni ändå kan rädda genom att föra över',
'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',
+ 'Planer ändras, och det här är bara en prognos utifrån kalendern som den ser ut i dag. Om den håller hamnar {surplus_parent} med {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 föra över 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. Håll saldona här uppdaterade efter hand, så att prognosen bygger på vad ni faktiskt tog ut.',
+ 'tip.age4PoolingLossHedged.title': 'Dagar ni kanske kan rädda genom att föra över',
'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.title': 'Obalans mellan föräldrarna',
@@ -13,7 +13,7 @@ export const tips = {
'tip.lagstaNivaCoverage.title': 'Täck upp med lägstanivå',
'tip.longBreakParentSwap.body': '{parent_name} täcker mindre än hälften av "{break_name}" ({start_date}–{end_date}). Låt {other_parent_name} ta en del av perioden.',
'tip.longBreakParentSwap.title': 'Överväg att växla av varandra',
- 'tip.preserveForAge4.body': '{parent_name} riskerar att förlora {expiring_days} dagar när barnet fyller 4 år ({fourth_birthday}). Endast 96 dagar kan sparas efter det.',
+ 'tip.preserveForAge4.body': '{parent_name} riskerar att förlora {expiring_days} dagar när barnet fyller 4 år ({fourth_birthday}). Endast {parent_cap} dagar kan sparas per förälder efter det.',
'tip.preserveForAge4.title': 'Dagar riskerar att gå förlorade vid 4 års ålder',
'tip.reserveForPreschoolPlanningDays.body': '{parent_name} har {days_left} dagar kvar, vilket knappt räcker för förskolans {buffer_days} planeringsdagar. Reservera dagar i god tid.',
'tip.reserveForPreschoolPlanningDays.title': 'Reservera dagar för förskolans planeringsdagar',
diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx
index af2fe2b..ee9ad70 100644
--- a/frontend/src/pages/Dashboard.test.tsx
+++ b/frontend/src/pages/Dashboard.test.tsx
@@ -275,6 +275,44 @@ describe('Dashboard', () => {
renderWithRouter()
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('1 juni 2029')
})
+
+ it('renders the remedy line when an age4_pooling_loss_exact tip carries a well-formed remedy', () => {
+ vi.mocked(hooks.useOptimizationTips).mockReturnValue(
+ makeOptimizationTipsResult({
+ data: [
+ makeTip({
+ id: 'tip-remedy',
+ messageKey: 'tips.age4_pooling_loss_exact',
+ data: { transfer_days: 24, surplus_parent: 'Jacob', lagging_parent: 'Amelie' },
+ }),
+ ],
+ }),
+ )
+ renderWithRouter()
+ expect(screen.getByTestId('dashboard-hero-remedy')).toHaveTextContent('24 dagar från Jacob till Amelie')
+ })
+
+ it('renders only the warning, without crashing, when the tip\'s data is missing or wrong-typed', () => {
+ vi.mocked(hooks.useOptimizationTips).mockReturnValue(
+ makeOptimizationTipsResult({
+ data: [
+ makeTip({
+ id: 'tip-malformed',
+ messageKey: 'tips.age4_pooling_loss_exact',
+ // Deliberately malformed: `transfer_days` is a string, not
+ // the number the tip contract promises. `data` is typed as
+ // `Record` so this needs no cast — it's
+ // exactly the shape `deriveHeroRemedy`'s `typeof` guards
+ // must reject.
+ data: { transfer_days: 'lots', surplus_parent: 'Jacob', lagging_parent: 'Amelie' },
+ }),
+ ],
+ }),
+ )
+ renderWithRouter()
+ expect(screen.getByTestId('dashboard-hero-message')).toBeInTheDocument()
+ expect(screen.queryByTestId('dashboard-hero-remedy')).not.toBeInTheDocument()
+ })
})
it('renders the three key figures from the combined analytics', () => {
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index f656edc..089bb51 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -26,6 +26,7 @@ import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip'
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { TipCard } from '@/components/inputs/TipCard'
import { Onboarding } from '@/pages/Onboarding'
+import type { Tip } from '@/types'
/** Fallback horizon end when the analytics projection isn't available yet
* (e.g. mid-load) — plain date arithmetic, not a business rule, so deriving
@@ -36,6 +37,29 @@ function computeFourthBirthday(birthDateIso: string): string {
return d.toISOString().slice(0, 10)
}
+interface HeroRemedy {
+ transferDays: number
+ fromParent: string
+ toParent: string
+}
+
+/** Surfaces the exact age-4 remedy in the hero, derived from the
+ * `tips.age4_pooling_loss_exact` tip's `data` — no separate API call, no
+ * new backend field. `Tip['data']` is `Record`, so every
+ * field is narrowed by hand rather than cast. */
+function deriveHeroRemedy(tips: Tip[] | null): HeroRemedy | null {
+ const tip = tips?.find((t) => t.messageKey === 'tips.age4_pooling_loss_exact')
+ if (!tip) return null
+
+ const { transfer_days, surplus_parent, lagging_parent } = tip.data
+
+ if (typeof transfer_days !== 'number' || !Number.isFinite(transfer_days) || transfer_days <= 0) return null
+ if (typeof surplus_parent !== 'string' || surplus_parent.length === 0) return null
+ if (typeof lagging_parent !== 'string' || lagging_parent.length === 0) return null
+
+ return { transferDays: transfer_days, fromParent: surplus_parent, toParent: lagging_parent }
+}
+
export function Dashboard(): ReactNode {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -189,6 +213,7 @@ export function Dashboard(): ReactNode {
const surplusAdvice = proposal?.surplusAdvice ?? []
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0
+ const heroRemedy = deriveHeroRemedy(tips)
return (
@@ -206,6 +231,7 @@ export function Dashboard(): ReactNode {
daysAtRisk={analytics.combinedExpiringAtB4}
childName={selectedChild.name}
fourthBirthday={fourthBirthday}
+ remedy={heroRemedy}
/>
)}
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index 29b15e4..eadcec0 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -38,6 +38,18 @@
color: var(--c-text);
}
+.dashboard-hero__remedy {
+ margin: var(--sp-2) 0 0;
+ max-width: 72ch;
+ font-size: var(--fs-sm);
+ color: var(--c-text-muted);
+}
+
+.dashboard-hero__remedy-label {
+ font-weight: 500;
+ color: var(--c-text);
+}
+
/* One muted line under the hero while the figures still rest on the
* statutory assumption for at least one parent. The lead word (the e2e
* hook) is the only emphasised part. */
diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts
index a2e7976..f9a69d7 100644
--- a/frontend/src/utils/constants.ts
+++ b/frontend/src/utils/constants.ts
@@ -46,3 +46,17 @@ export const LEAVE_RULES = {
// Statutory Swedish vacation days
vacationDaysPerYear: 25,
} as const;
+
+/**
+ * Försäkringskassan's BankID login page, carrying a `goto` fragment so the
+ * user lands on the "Min föräldrapenning" e-service after authenticating —
+ * the page that shows "Dagar ni kan föra över" and where a transfer between
+ * parents is actually made. Mirrors the URL shapes in
+ * `backend/app/scrapers/fk_scraper.py` (`LOGIN_URL` / `MIN_FP_URL`); keep in
+ * sync if FK moves the e-service.
+ *
+ * @see backend/app/scrapers/fk_scraper.py#LOGIN_URL
+ */
+export const FK_TRANSFER_LOGIN_URL =
+ 'https://www.forsakringskassan.se/privatperson/logga-in' +
+ '#/?goto=%2Fprivatperson%2Fmina-sidor%2Falla-e-tjanster%2Fmin-foraldrapenning';