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
+9 -5
View File
@@ -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
+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)
@@ -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,
+120 -1
View File
@@ -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(
<Hero
hasPlan={true}
daysLeft={200}
daysAtRisk={191}
childName="Nova"
fourthBirthday="2029-06-01"
remedy={{ transferDays: 24, fromParent: 'Jacob', toParent: 'Amelie' }}
/>,
)
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(
<Hero hasPlan={true} daysLeft={200} daysAtRisk={191} childName="Nova" fourthBirthday="2029-06-01" remedy={null} />,
)
expect(screen.queryByTestId('dashboard-hero-remedy')).not.toBeInTheDocument()
})
it('omits the remedy line when remedy is omitted entirely', () => {
render(<Hero hasPlan={true} daysLeft={200} daysAtRisk={191} childName="Nova" fourthBirthday="2029-06-01" />)
expect(screen.queryByTestId('dashboard-hero-remedy')).not.toBeInTheDocument()
})
it('omits the remedy line when hasPlan is false, even with a remedy supplied', () => {
render(
<Hero
hasPlan={false}
daysLeft={480}
daysAtRisk={384}
childName="Nova"
fourthBirthday="2029-06-01"
remedy={{ transferDays: 24, fromParent: 'Jacob', toParent: 'Amelie' }}
/>,
)
expect(screen.queryByTestId('dashboard-hero-remedy')).not.toBeInTheDocument()
})
it('omits the remedy line when daysAtRisk is 0, even with a remedy supplied', () => {
render(
<Hero
hasPlan={true}
daysLeft={200}
daysAtRisk={0}
childName="Nova"
fourthBirthday="2029-06-01"
remedy={{ transferDays: 24, fromParent: 'Jacob', toParent: 'Amelie' }}
/>,
)
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(
<Hero
hasPlan={true}
daysLeft={200}
daysAtRisk={191}
childName="Nova"
fourthBirthday="2029-06-01"
remedy={{ transferDays: 24, fromParent: 'Jacob', toParent: 'Amelie' }}
/>,
)
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(
<LanguageProvider>
<Hero
hasPlan={true}
daysLeft={200}
daysAtRisk={191}
childName="Nova"
fourthBirthday="2029-06-01"
remedy={{ transferDays: 24, fromParent: 'Jacob', toParent: 'Amelie' }}
/>
</LanguageProvider>,
)
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(<Hero hasPlan={true} daysLeft={200} daysAtRisk={191} childName="Nova" fourthBirthday="2029-06-01" />)
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(
<Hero hasPlan={false} daysLeft={480} daysAtRisk={104} childName="Nova" fourthBirthday="2029-06-01" />,
+46 -1
View File
@@ -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 <a> 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 <a> 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 (
<section
data-testid="dashboard-hero"
@@ -50,6 +78,23 @@ export function Hero({ hasPlan, daysLeft, daysAtRisk, childName, fourthBirthday
<p className="dashboard-hero__message" data-testid="dashboard-hero-message">
{message}
</p>
{hasPlan && !noneAtRisk && remedy && (
<p className="dashboard-hero__remedy" data-testid="dashboard-hero-remedy">
<span className="dashboard-hero__remedy-label">{t('dashboard.hero.remedyLabel')}</span>{' '}
{remedyBeforeLink}
{remedyAfterLink !== undefined ? (
<a
className="dashboard-hero__fk-link"
href={FK_TRANSFER_LOGIN_URL}
target="_blank"
rel="noopener noreferrer"
>
{t('dashboard.hero.fkLinkText')}
</a>
) : null}
{remedyAfterLink}
</p>
)}
</section>
)
}
+3
View File
@@ -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',
+5 -5
View File
@@ -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',
+3
View File
@@ -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',
+5 -5
View File
@@ -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',
+38
View File
@@ -275,6 +275,44 @@ describe('Dashboard', () => {
renderWithRouter(<Dashboard />)
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(<Dashboard />)
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<string, unknown>` 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(<Dashboard />)
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', () => {
+26
View File
@@ -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<string, unknown>`, 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 (
<Layout>
@@ -206,6 +231,7 @@ export function Dashboard(): ReactNode {
daysAtRisk={analytics.combinedExpiringAtB4}
childName={selectedChild.name}
fourthBirthday={fourthBirthday}
remedy={heroRemedy}
/>
)}
+12
View File
@@ -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. */
+14
View File
@@ -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';