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:
@@ -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" />,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user