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
+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>
)
}