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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user