Make the overview answer the year-by-year question

The dashboard now leads with a typographic headline, three key
figures, per-parent age-4 bars and one row per year until the fourth
birthday. Each year row shows the timeline bands beside each parent's
parental-leave days left at year end and vacation days left, carried
over or expiring, and opens into a per-parent table with that year's
wish outcomes. Tips become a plain list and absorb the planner's
surplus advice; "Detaljer" keeps the metrics and a balance snapshot
for every parent. The separate plan-proposal fold and the
type-count table are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-20 06:30:46 +02:00
parent 4b802ba0be
commit 80b4f1cb52
30 changed files with 2444 additions and 3477 deletions
@@ -1,57 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { DaysBreakdown, type DaysBreakdownProps } from './DaysBreakdown'
describe('DaysBreakdown', () => {
it('renders empty state when no breakdown provided', () => {
const { getByTestId } = render(<DaysBreakdown breakdown={[]} />)
expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än')
})
it('renders empty state when breakdown itself is missing (defensive null/undefined guard)', () => {
const { getByTestId } = render(
<DaysBreakdown breakdown={undefined as unknown as DaysBreakdownProps['breakdown']} />
)
expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än')
})
it('renders table with breakdown data', () => {
const breakdown = [
{ type: 'vacation', count: 10 },
{ type: 'sick leave', count: 3 },
{ type: 'parental', count: 50 },
]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
const table = getByTestId('days-breakdown')
expect(table.querySelector('table')).toBeInTheDocument()
expect(table.textContent).toContain('vacation')
expect(table.textContent).toContain('10')
})
it('renders correct number of rows', () => {
const breakdown = [
{ type: 'vacation', count: 10 },
{ type: 'sick leave', count: 3 },
{ type: 'parental', count: 50 },
]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
expect(getByTestId('breakdown-row-vacation')).toBeInTheDocument()
expect(getByTestId('breakdown-row-sick leave')).toBeInTheDocument()
expect(getByTestId('breakdown-row-parental')).toBeInTheDocument()
})
it('displays correct values in table', () => {
const breakdown = [{ type: 'vacation', count: 25 }]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
const row = getByTestId('breakdown-row-vacation')
expect(row.textContent).toContain('vacation')
expect(row.textContent).toContain('25')
})
it('renders table headers', () => {
const breakdown = [{ type: 'vacation', count: 10 }]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
const table = getByTestId('days-breakdown').querySelector('table')
expect(table?.querySelector('thead')).toBeInTheDocument()
})
})
@@ -1,39 +0,0 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
export interface DaysBreakdownProps {
breakdown: Array<{ type: string; count: number }>
}
export function DaysBreakdown({ breakdown }: DaysBreakdownProps): ReactNode {
const { t } = useTranslation()
if (!breakdown || breakdown.length === 0) {
return (
<div data-testid="days-breakdown" className="days-breakdown">
<p className="days-breakdown__empty">{t('daysBreakdown.empty')}</p>
</div>
)
}
return (
<div data-testid="days-breakdown" className="days-breakdown">
<table className="days-breakdown__table">
<thead>
<tr>
<th>{t('daysBreakdown.typeHeader')}</th>
<th>{t('daysBreakdown.daysHeader')}</th>
</tr>
</thead>
<tbody>
{breakdown.map((item, idx) => (
<tr key={idx} data-testid={`breakdown-row-${item.type}`}>
<td>{item.type}</td>
<td>{item.count}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { KeyFigures } from './KeyFigures'
describe('KeyFigures', () => {
it('renders the three combined figures with their labels', () => {
render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} />)
expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('270')
expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('Dagar kvar')
expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('30')
expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('Använda')
expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('100')
expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('Förfaller vid 4 år')
})
it('wears danger ink on the expiring figure only when it is non-zero', () => {
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} />)
expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).toHaveClass(
'key-figures__value--danger',
)
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} />)
expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).not.toHaveClass(
'key-figures__value--danger',
)
expect(screen.getByTestId('dashboard-figure-left').querySelector('dd')).not.toHaveClass(
'key-figures__value--danger',
)
})
})
@@ -0,0 +1,37 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
export interface KeyFiguresProps {
/** `combinedDaysLeft` — days still available to plan, all parents. */
daysLeft: number
/** `combinedDaysUsed`. */
daysUsed: number
/** `combinedExpiringAtB4` — projected to be lost at the 4th birthday. */
expiringAtB4: number
}
/**
* The three headline figures under the hero — large tabular numerals with
* a 14px label each, no tiles. The expiring figure wears danger ink only
* when it is non-zero: a zero is good news and should read as quiet.
*/
export function KeyFigures({ daysLeft, daysUsed, expiringAtB4 }: KeyFiguresProps): ReactNode {
const { t } = useTranslation()
const figures = [
{ key: 'left', label: t('dashboard.figureLeft'), value: daysLeft, danger: false },
{ key: 'used', label: t('dashboard.figureUsed'), value: daysUsed, danger: false },
{ key: 'expiring', label: t('dashboard.figureExpiring'), value: expiringAtB4, danger: expiringAtB4 > 0 },
]
return (
<dl className="key-figures" data-testid="key-figures">
{figures.map((figure) => (
<div key={figure.key} className="key-figures__item" data-testid={`dashboard-figure-${figure.key}`}>
<dt className="key-figures__label">{figure.label}</dt>
<dd className={figure.danger ? 'key-figures__value key-figures__value--danger' : 'key-figures__value'}>
{figure.value}
</dd>
</div>
))}
</dl>
)
}
@@ -4,103 +4,67 @@ import { ParentalLeaveBreakdown } from './ParentalLeaveBreakdown'
import { LEAVE_RULES } from '@/utils/constants'
import { LanguageProvider } from '@/i18n'
import type { ParentalLeaveFetch } from '@/types'
import { makeParentalLeaveFetch, makeTierData } from '../../../tests/fixtures'
const alexData = makeParentalLeaveFetch({
personalDataUuid: 'pd-a',
source: 'scraped',
fetchedAt: '2026-06-13T10:00:00Z',
transferableDaysLeft: 50,
dubbeldagarLeft: 60,
tierData: makeTierData({ sjukpenningLeft: 80, sjukpenningTotal: 100, lagstaNivaLeft: 170, lagstaNivaTotal: 200 }),
})
const alex = { personalDataUuid: 'pd-a', name: 'Alex', data: alexData }
const samWithout = { personalDataUuid: 'pd-b', name: 'Sam', data: null }
describe('ParentalLeaveBreakdown', () => {
const mockData: ParentalLeaveFetch = {
id: 'pf1',
personalDataUuid: 'pd1',
workspaceId: 'ws1',
childId: 'c1',
source: 'scraped',
fetchedAt: '2026-06-13T10:00:00Z',
totalDays: 300,
daysUsed: 50,
daysLeft: 250,
reservedDaysTotal: 90,
reservedDaysLeft: 80,
transferableDaysLeft: 50,
dubbeldagarLeft: 60,
tierData: {
sjukpenningTotal: 100,
sjukpenningUsed: 20,
sjukpenningLeft: 80,
lagstaNivaTotal: 200,
lagstaNivaUsed: 30,
lagstaNivaLeft: 170,
},
createdAt: '2026-06-13T10:00:00Z',
updatedAt: '2026-06-13T10:00:00Z',
}
it('renders empty state when data is null', () => {
render(<ParentalLeaveBreakdown data={null} />)
expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument()
it('renders an empty state when there are no parents', () => {
render(<ParentalLeaveBreakdown parents={[]} />)
expect(screen.getByTestId('parental-leave-breakdown')).toHaveTextContent('Inga saldon hämtade än.')
})
it('omits the tier rows when tierData is absent', () => {
const dataWithoutTiers = { ...mockData, tierData: undefined } as unknown as ParentalLeaveFetch
render(<ParentalLeaveBreakdown data={dataWithoutTiers} />)
expect(screen.queryByTestId('breakdown-sjukpenning')).not.toBeInTheDocument()
expect(screen.queryByTestId('breakdown-lagsta-niva')).not.toBeInTheDocument()
// The rows that don't depend on tierData still render.
expect(screen.getByTestId('breakdown-transferable')).toBeInTheDocument()
it('renders one column per parent with each tier as left-of-total', () => {
render(<ParentalLeaveBreakdown parents={[alex, samWithout]} />)
expect(screen.getByRole('columnheader', { name: 'Alex' })).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'Sam' })).toBeInTheDocument()
expect(screen.getByTestId('breakdown-sjukpenning-pd-a')).toHaveTextContent('80 av 100')
expect(screen.getByTestId('breakdown-lagsta-niva-pd-a')).toHaveTextContent('170 av 200')
expect(screen.getByTestId('breakdown-transferable-pd-a')).toHaveTextContent('50')
expect(screen.getByTestId('breakdown-dubbeldagar-pd-a')).toHaveTextContent('60')
})
it('renders breakdown table with data', () => {
render(<ParentalLeaveBreakdown data={mockData} />)
expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument()
expect(screen.getByTestId('breakdown-transferable')).toBeInTheDocument()
it('renders dashes and a "no balance yet" note for a parent without a snapshot', () => {
render(<ParentalLeaveBreakdown parents={[alex, samWithout]} />)
expect(screen.getByTestId('breakdown-sjukpenning-pd-b')).toHaveTextContent('–')
expect(screen.getByTestId('breakdown-dubbeldagar-pd-b')).toHaveTextContent('–')
expect(screen.getByTestId('breakdown-provenance-pd-b')).toHaveTextContent('Sam Inget saldo än')
})
it('does not render a reserved-days countdown row', () => {
render(<ParentalLeaveBreakdown data={mockData} />)
expect(screen.queryByTestId('breakdown-reserved')).not.toBeInTheDocument()
it('renders dashes for the tier rows when tierData is absent', () => {
const noTiers = { ...alexData, tierData: undefined } as unknown as ParentalLeaveFetch // deliberately malformed wire data
render(<ParentalLeaveBreakdown parents={[{ ...alex, data: noTiers }]} />)
expect(screen.getByTestId('breakdown-sjukpenning-pd-a')).toHaveTextContent('–')
expect(screen.getByTestId('breakdown-lagsta-niva-pd-a')).toHaveTextContent('–')
expect(screen.getByTestId('breakdown-transferable-pd-a')).toHaveTextContent('50')
})
it('displays a reserved-days explanatory note instead of a countdown', () => {
render(<ParentalLeaveBreakdown data={mockData} />)
const note = screen.getByTestId('reserved-days-note')
expect(note).toHaveTextContent(`${LEAVE_RULES.reservedDaysPerParent} av dina dagar är reserverade`)
expect(note).toHaveTextContent(`max ${LEAVE_RULES.transferableDaysPerParent} dagar kan överlåtas`)
it('displays the reserved-days note once, not per parent', () => {
render(<ParentalLeaveBreakdown parents={[alex, samWithout]} />)
const notes = screen.getAllByTestId('reserved-days-note')
expect(notes).toHaveLength(1)
expect(notes[0]).toHaveTextContent(`${LEAVE_RULES.reservedDaysPerParent} av varje förälders dagar är reserverade`)
expect(notes[0]).toHaveTextContent(`max ${LEAVE_RULES.transferableDaysPerParent} dagar kan överlåtas`)
})
it('displays transferable days', () => {
render(<ParentalLeaveBreakdown data={mockData} />)
const row = screen.getByTestId('breakdown-transferable')
expect(row).toHaveTextContent('50')
it('shows the scraped source and date per parent', () => {
render(<ParentalLeaveBreakdown parents={[alex]} />)
expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('Alex Hämtad från Försäkringskassan 2026-06-13')
})
it('displays dubbeldagar left', () => {
render(<ParentalLeaveBreakdown data={mockData} />)
const row = screen.getByTestId('breakdown-dubbeldagar')
expect(row).toHaveTextContent('60')
})
it('displays tier data', () => {
render(<ParentalLeaveBreakdown data={mockData} />)
const sjukRow = screen.getByTestId('breakdown-sjukpenning')
expect(sjukRow).toHaveTextContent('80')
expect(sjukRow).toHaveTextContent('100')
const lagstaRow = screen.getByTestId('breakdown-lagsta-niva')
expect(lagstaRow).toHaveTextContent('170')
expect(lagstaRow).toHaveTextContent('200')
})
it('displays scraped source label', () => {
render(<ParentalLeaveBreakdown data={mockData} />)
expect(screen.getByText(/Hämtad från Försäkringskassan/)).toBeInTheDocument()
})
it('displays manual source label', () => {
const manualData = { ...mockData, source: 'manual' as const }
render(<ParentalLeaveBreakdown data={manualData} />)
expect(screen.getByText(/Manuellt angiven/)).toBeInTheDocument()
})
it('displays formatted date', () => {
render(<ParentalLeaveBreakdown data={mockData} />)
expect(screen.getByText(/2026-06-13/)).toBeInTheDocument()
it('shows the manual source label', () => {
render(<ParentalLeaveBreakdown parents={[{ ...alex, data: { ...alexData, source: 'manual' } }]} />)
expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('Manuellt angiven')
})
it('formats the provenance date using the en-US locale when the language is English', () => {
@@ -114,11 +78,12 @@ describe('ParentalLeaveBreakdown', () => {
try {
render(
<LanguageProvider>
<ParentalLeaveBreakdown data={mockData} />
<ParentalLeaveBreakdown parents={[alex]} />
</LanguageProvider>,
)
// en-US formats as M/D/YYYY, distinct from the sv-SE "2026-06-13" form.
expect(screen.getByText(/6\/13\/2026/)).toBeInTheDocument()
expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('6/13/2026')
expect(screen.getByTestId('breakdown-sjukpenning-pd-a')).toHaveTextContent('80 of 100')
} finally {
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
vi.unstubAllGlobals()
@@ -3,14 +3,27 @@ import type { ParentalLeaveFetch } from '@/types'
import { LEAVE_RULES } from '@/utils/constants'
import { useTranslation } from '@/i18n'
export interface ParentalLeaveBreakdownProps {
/** One parent's balance snapshot — `data` is `null` when nothing has been
* fetched or entered for them yet. */
export interface ParentalLeaveBreakdownParent {
personalDataUuid: string
name: string
data: ParentalLeaveFetch | null
}
export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): ReactNode {
export interface ParentalLeaveBreakdownProps {
parents: ParentalLeaveBreakdownParent[]
}
/**
* The raw balance snapshot per parent (one column each): what is left of
* each tier, transferable and double days, and where the figures came from.
* Lives inside the dashboard's "Detaljer" disclosure.
*/
export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps): ReactNode {
const { t, locale } = useTranslation()
if (!data) {
if (parents.length === 0) {
return (
<div data-testid="parental-leave-breakdown" className="parental-leave-breakdown">
<p className="parental-leave-breakdown__empty">{t('parentalLeaveBreakdown.empty')}</p>
@@ -18,54 +31,72 @@ export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): R
)
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US')
}
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US')
const sourceLabel =
data.source === 'scraped'
? t('parentalLeaveBreakdown.sourceScraped')
: t('parentalLeaveBreakdown.sourceManual')
const cell = (parent: ParentalLeaveBreakdownParent, testId: string, render: (data: ParentalLeaveFetch) => ReactNode) => (
<td key={parent.personalDataUuid} className="parental-leave-breakdown__num tnum" data-testid={`${testId}-${parent.personalDataUuid}`}>
{parent.data ? render(parent.data) : '–'}
</td>
)
const leftOfTotal = (left: number, total: number) => t('parentalLeaveBreakdown.leftOfTotal', { left, total })
return (
<div data-testid="parental-leave-breakdown" className="parental-leave-breakdown">
<div className="parental-leave-breakdown__section">
<table className="parental-leave-breakdown__table">
<thead>
<tr>
<th>{t('parentalLeaveBreakdown.typeHeader')}</th>
<th>{t('parentalLeaveBreakdown.remainingHeader')}</th>
<th>{t('parentalLeaveBreakdown.totalHeader')}</th>
</tr>
</thead>
<tbody>
<tr data-testid="breakdown-transferable">
<td>{t('parentalLeaveBreakdown.transferableDays')}</td>
<td>{data.transferableDaysLeft}</td>
<td>—</td>
</tr>
<tr data-testid="breakdown-dubbeldagar">
<td>{t('parentalLeaveBreakdown.dubbeldagarLeft')}</td>
<td>{data.dubbeldagarLeft}</td>
<td>—</td>
</tr>
{data.tierData && (
<>
<tr data-testid="breakdown-sjukpenning">
<td>{t('parentalLeaveBreakdown.sjukpenning')}</td>
<td>{data.tierData.sjukpenningLeft}</td>
<td>{data.tierData.sjukpenningTotal}</td>
</tr>
<tr data-testid="breakdown-lagsta-niva">
<td>{t('parentalLeaveBreakdown.lagstaNiva')}</td>
<td>{data.tierData.lagstaNivaLeft}</td>
<td>{data.tierData.lagstaNivaTotal}</td>
</tr>
</>
<table className="parental-leave-breakdown__table">
<thead>
<tr>
<th scope="col" />
{parents.map((parent) => (
<th key={parent.personalDataUuid} scope="col" className="parental-leave-breakdown__num">
{parent.name}
</th>
))}
</tr>
</thead>
<tbody>
<tr data-testid="breakdown-sjukpenning">
<th scope="row">{t('parentalLeaveBreakdown.sjukpenning')}</th>
{parents.map((parent) =>
cell(parent, 'breakdown-sjukpenning', (d) =>
d.tierData ? leftOfTotal(d.tierData.sjukpenningLeft, d.tierData.sjukpenningTotal) : '–',
),
)}
</tbody>
</table>
</div>
</tr>
<tr data-testid="breakdown-lagsta-niva">
<th scope="row">{t('parentalLeaveBreakdown.lagstaNiva')}</th>
{parents.map((parent) =>
cell(parent, 'breakdown-lagsta-niva', (d) =>
d.tierData ? leftOfTotal(d.tierData.lagstaNivaLeft, d.tierData.lagstaNivaTotal) : '–',
),
)}
</tr>
<tr data-testid="breakdown-transferable">
<th scope="row">{t('parentalLeaveBreakdown.transferableDays')}</th>
{parents.map((parent) => cell(parent, 'breakdown-transferable', (d) => d.transferableDaysLeft))}
</tr>
<tr data-testid="breakdown-dubbeldagar">
<th scope="row">{t('parentalLeaveBreakdown.dubbeldagarLeft')}</th>
{parents.map((parent) => cell(parent, 'breakdown-dubbeldagar', (d) => d.dubbeldagarLeft))}
</tr>
</tbody>
</table>
<ul className="parental-leave-breakdown__provenance">
{parents.map((parent) => (
<li key={parent.personalDataUuid} data-testid={`breakdown-provenance-${parent.personalDataUuid}`}>
<span className="parental-leave-breakdown__provenance-name">{parent.name}</span>{' '}
{parent.data
? `${
parent.data.source === 'scraped'
? t('parentalLeaveBreakdown.sourceScraped')
: t('parentalLeaveBreakdown.sourceManual')
} ${formatDate(parent.data.fetchedAt)}`
: t('parentalLeaveBreakdown.noBalance')}
</li>
))}
</ul>
<p className="parental-leave-breakdown__reserved-note" data-testid="reserved-days-note">
{t('parentalLeaveBreakdown.reservedNote', {
@@ -73,12 +104,6 @@ export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): R
transferable: LEAVE_RULES.transferableDaysPerParent,
})}
</p>
<div className="parental-leave-breakdown__provenance">
<small>
{sourceLabel} {formatDate(data.fetchedAt)}
</small>
</div>
</div>
)
}
@@ -1,344 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { PlanFold } from './PlanFold'
import * as hooks from '@/hooks'
import {
makeLeaveWishesResult,
makePlanProposalResult,
makePersonalData,
makeLeaveWish,
makePlanProposal,
makeWindowOutcome,
makeSurplusAdvice,
makeShortfallRemedy,
makePoolBalance,
makeVacationBalance,
} from '../../../tests/fixtures'
vi.mock('@/hooks')
const members = [
makePersonalData({ uuid: 'pd-a', name: 'Alex' }),
makePersonalData({ uuid: 'pd-b', name: 'Sam' }),
]
describe('PlanFold', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [makeLeaveWish()] }))
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult())
})
it('is collapsed by default (a native <details> element)', () => {
render(<PlanFold workspaceId="ws-1" members={members} />)
const details = screen.getByTestId('plan-fold')
expect(details.tagName).toBe('DETAILS')
expect(details).not.toHaveAttribute('open')
})
it('shows a hint instead of a proposal when there are no wishes', () => {
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-no-wishes')).toHaveTextContent(
'Lägg till önskemål under Kalender för att se ett förslag här.',
)
})
it('shows the proposal error when the read-only propose call fails', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({ error: 'Kunde inte beräkna förslag' }),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-proposal-error')).toHaveTextContent('Kunde inte beräkna förslag')
})
it('auto-proposes once wishes are loaded, without a manual button press', () => {
const propose = vi.fn()
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(propose).toHaveBeenCalled()
})
it('does not propose again once a proposal is already loaded', () => {
const propose = vi.fn()
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({ propose, data: makePlanProposal() }),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(propose).not.toHaveBeenCalled()
})
it('does not propose while there are no wishes yet', () => {
const propose = vi.fn()
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] }))
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(propose).not.toHaveBeenCalled()
})
it('does not propose while wishes are still loading', () => {
const propose = vi.fn()
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ isLoading: true, data: [] }))
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(propose).not.toHaveBeenCalled()
})
it('does not propose again while a propose call is already in flight', () => {
const propose = vi.fn()
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose, isLoading: true }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(propose).not.toHaveBeenCalled()
})
it('renders per-period wished-vs-funded from the proposal', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
outcomes: [makeWindowOutcome({ wishId: 'lw-1', wishName: 'Sommar', year: 2026, weeksWished: 6, weeksFunded: 4 })],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-overview-year-2026')).toHaveTextContent('Sommar: 4/6 veckor')
})
it('shows the empty-horizon message when the proposal has no outcomes', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({ data: makePlanProposal({ outcomes: [] }) }),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-overview')).toHaveTextContent('Inga fönster inom planeringshorisonten.')
})
it('renders surplus advice with a resolved parent name, never a raw uuid', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
surplusAdvice: [
makeSurplusAdvice({
kind: 'wish_more_leave',
messageKey: 'planSurplusAdvice.wish_more_leave',
daysInvolved: 12,
data: { parent_personal_data_uuid: 'pd-b', child_id: 'child-1', weeks: 2 },
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
expect(advice).toHaveTextContent('Sam')
expect(advice).not.toHaveTextContent('pd-b')
})
it('falls back to a generic label for surplus advice when no member matches', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
surplusAdvice: [
makeSurplusAdvice({
kind: 'wish_more_leave',
messageKey: 'planSurplusAdvice.wish_more_leave',
daysInvolved: 12,
data: { parent_personal_data_uuid: 'pd-unknown', child_id: 'child-1', weeks: 2 },
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
expect(advice).not.toHaveTextContent('pd-unknown')
})
it('renders an unrecognised surplus advice kind/messageKey by falling back to the raw value', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
surplusAdvice: [
makeSurplusAdvice({
kind: 'brand_new_kind',
messageKey: 'planSurplusAdvice.brand_new_kind',
data: {},
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
expect(advice).toHaveTextContent('brand_new_kind')
})
it('does not render a surplus-advice section when there is none', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({ data: makePlanProposal({ surplusAdvice: [] }) }),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.queryByTestId('plan-fold-surplus-advice')).not.toBeInTheDocument()
})
it('renders pool and vacation balance tables', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
poolBalances: [makePoolBalance({ parentPersonalDataUuid: 'pd-a', sjukpenningLeft: 300, lagstaLeft: 80, dubbeldagarLeft: 50 })],
vacationBalances: [makeVacationBalance({ parentPersonalDataUuid: 'pd-a', year: 2026, alreadyUsed: 10, totalDays: 25, savedDaysIn: 0 })],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-pool-balances')).toHaveTextContent('Alex')
expect(screen.getByTestId('plan-fold-pool-balances')).toHaveTextContent('300')
expect(screen.getByTestId('plan-fold-vacation-balances')).toHaveTextContent('25')
})
it('does not render a shortfalls section when no window has a shortfall', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({ outcomes: [makeWindowOutcome({ shortfallDays: 0, remedies: [] })] }),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.queryByTestId('plan-fold-shortfalls')).not.toBeInTheDocument()
})
it('renders the shortfall line and its remedies for a window that ran short', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-1',
wishName: 'Sportlov',
year: 2030,
shortfallDays: 2,
remedies: [
makeShortfallRemedy({
kind: 'carry_vacation_forward',
messageKey: 'planRemedies.carry_vacation_forward',
data: { spare_total: 5 },
daysFreed: 5,
}),
],
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
const shortfall = screen.getByTestId('plan-fold-shortfall-lw-1-2030')
expect(shortfall).toHaveTextContent('Sportlov 2030')
expect(shortfall).toHaveTextContent('2 dag(ar) kunde inte finansieras')
expect(screen.getByTestId('plan-fold-remedy-lw-1-2030-0')).toHaveTextContent(
'5 semesterdagar från tidigare år kan sparas framåt',
)
})
it('renders the widened shift-window remedy with its concrete dates', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-2',
wishName: 'Vårvecka',
year: 2028,
shortfallDays: 5,
remedies: [
makeShortfallRemedy({
kind: 'shift_window_into_free_days',
messageKey: 'planRemedies.shift_window_into_free_days',
data: {
wish_name: 'Vårvecka',
new_start_date: '2028-04-11',
new_end_date: '2028-04-17',
shift_gain: 2,
},
daysFreed: 2,
}),
],
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-remedy-lw-2-2028-0')).toHaveTextContent(
'2028-04-11 – 2028-04-17',
)
})
it('renders a window with multiple remedies as one line each', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-3',
year: 2027,
shortfallDays: 4,
remedies: [
makeShortfallRemedy({
kind: 'reduce_preschool_reserve',
messageKey: 'planRemedies.reduce_preschool_reserve',
data: { reserved: 3 },
daysFreed: 3,
}),
makeShortfallRemedy({
kind: 'lower_priority_wish',
messageKey: 'planRemedies.lower_priority_wish',
data: { wish_name: 'Höstlov', from_weeks: 2, to_weeks: 1 },
daysFreed: 4,
}),
],
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-remedy-lw-3-2027-0')).toHaveTextContent(
'Minska förskolans planeringsdagar (3 reserverade) frigör 3 dagar',
)
expect(screen.getByTestId('plan-fold-remedy-lw-3-2027-1')).toHaveTextContent(
'Sänk Höstlov från 2 till 1 veckor frigör 4 dagar',
)
})
it('falls back to the raw messageKey for an unrecognised remedy kind', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-4',
year: 2027,
shortfallDays: 1,
remedies: [
makeShortfallRemedy({
kind: 'brand_new_kind',
messageKey: 'planRemedies.brand_new_kind',
data: {},
daysFreed: 1,
}),
],
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-remedy-lw-4-2027-0')).toHaveTextContent(
'planRemedies.brand_new_kind',
)
})
it('renders nothing beyond the summary/hint when there is no proposal yet', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ data: null }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.queryByTestId('plan-fold-overview')).not.toBeInTheDocument()
expect(screen.queryByTestId('plan-fold-balances')).not.toBeInTheDocument()
})
})
@@ -1,274 +0,0 @@
/**
* Folds the wished-vs-funded/surplus/balance content that used to live only
* on the Plan tab's `PlanResults.tsx` (since deleted) into Översikt,
* collapsed below the fold. This is now the sole implementation of that
* content — a trimmed-down component, not a shared import.
*
* Read-only. The calendar's `POST /api/plan/sync` (see `Calendar.tsx`) is
* now the only way a plan is written — it proposes and persists in one
* step, so there is no separate "propose, review, then apply" flow left
* anywhere in the app, and this fold never mutates anything. It still calls
* `usePlanProposal` (`POST /api/plan/propose`) to get the wished-vs-funded
* figures, surplus advice and balance tables below, because that data has
* no other source: there is no GET endpoint for "the current plan" shaped
* like a proposal, and `/api/plan/propose` is documented read-only —
* "persists nothing, safe to spam" (`backend/app/routers/plan.py`).
*
* Dropped relative to the old `PlanResults.tsx` (content not duplicated here
* since the Hero and `Age4Bars` already cover it): the warnings section and
* the age4Projections table. The per-window shortfall + remedies section
* (§todo item 13) is NOT dropped — see the "plan-fold-shortfalls" section
* below, a from-scratch render (not a port of the old detail cards).
* Also dropped, as of the move to `/api/plan/sync`: the apply button,
* collision-confirmation UI, and rejection rendering — applying is no
* longer a thing this fold does (see `frontend/src/utils/rejectionMessages.ts`
* for where rejection rendering now lives, for the sync toast in
* `Calendar.tsx`).
*/
import { ReactNode, useEffect } from 'react'
import type { PersonalData, ShortfallRemedy, SurplusAdvice, WindowOutcome } from '@/types'
import { useLeaveWishes, usePlanProposal } from '@/hooks'
import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
import { nameResolver } from '@/utils/parentNames'
import { resolveWishName } from '@/utils/wishName'
type Translate = (key: TranslationKey, params?: TranslationParams) => string
// §todo item 13, part 3 — the shortfall remedies `_build_remedies`
// (allocation_service.py) already computes and puts on the wire were never
// rendered anywhere (ideas.md §3's "the engine can't explain itself"
// complaint). Mirrors `SURPLUS_ADVICE_BODY_KEYS` below: `messageKey` ->
// the body template that interpolates the remedy's own `data`.
const REMEDY_BODY_KEYS: Record<string, TranslationKey> = {
'planRemedies.reduce_preschool_reserve': 'planResults.remedyBodyReducePreschoolReserve',
'planRemedies.carry_vacation_forward': 'planResults.remedyBodyCarryVacationForward',
'planRemedies.lower_priority_wish': 'planResults.remedyBodyLowerPriorityWish',
'planRemedies.shift_window_into_free_days': 'planResults.remedyBodyShiftWindowIntoFreeDays',
}
function renderRemedyLine(remedy: ShortfallRemedy, t: Translate): string {
const key = REMEDY_BODY_KEYS[remedy.messageKey]
if (!key) return remedy.messageKey
const params = toTranslationParams(remedy.data)
params.days_freed = remedy.daysFreed
return t(key, params)
}
// §9.2 surplus-advice kinds — originally mirrored the deleted `PlanResults.tsx`'s
// `SURPLUS_ADVICE_KEYS`/`SURPLUS_ADVICE_BODY_KEYS`; this is now the sole copy
// (see the file banner above).
const SURPLUS_ADVICE_KEYS: Record<string, TranslationKey> = {
wish_more_leave: 'planResults.surplusAdviceWishMoreLeave',
shift_mix_to_fp: 'planResults.surplusAdviceShiftMixToFp',
front_load_early_years: 'planResults.surplusAdviceFrontLoadEarlyYears',
use_less_vacation: 'planResults.surplusAdviceUseLessVacation',
}
const SURPLUS_ADVICE_BODY_KEYS: Record<string, TranslationKey> = {
'planSurplusAdvice.wish_more_leave': 'planResults.surplusAdviceBodyWishMoreLeave',
'planSurplusAdvice.shift_mix_to_fp': 'planResults.surplusAdviceBodyShiftMixToFp',
'planSurplusAdvice.front_load_early_years': 'planResults.surplusAdviceBodyFrontLoadEarlyYears',
'planSurplusAdvice.use_less_vacation': 'planResults.surplusAdviceBodyUseLessVacation',
}
function nameForSafe(members: PersonalData[], t: Translate): (uuid: string) => string {
const resolve = nameResolver(members)
return (uuid: string) => {
const name = resolve(uuid)
return name === uuid ? t('dashboard.planFold.unknownParent') : name
}
}
function renderSurplusAdviceBody(advice: SurplusAdvice, t: Translate, nameFor: (uuid: string) => string): string {
const key = SURPLUS_ADVICE_BODY_KEYS[advice.messageKey]
if (!key) return advice.messageKey
const params = toTranslationParams(advice.data)
const uuid = advice.data.parent_personal_data_uuid
if (typeof uuid === 'string') {
params.parent_name = nameFor(uuid)
}
params.days_involved = advice.daysInvolved
return t(key, params)
}
function groupByYear(outcomes: WindowOutcome[]): Array<{ year: number; outcomes: WindowOutcome[] }> {
const years = Array.from(new Set(outcomes.map((o) => o.year))).sort((a, b) => a - b)
return years.map((year) => ({
year,
outcomes: outcomes.filter((o) => o.year === year).sort((a, b) => a.startDate.localeCompare(b.startDate)),
}))
}
export interface PlanFoldProps {
workspaceId: string
members: PersonalData[]
}
export function PlanFold({ workspaceId, members }: PlanFoldProps): ReactNode {
const { t } = useTranslation()
const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspaceId)
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspaceId)
const nameFor = nameForSafe(members, t)
// Auto-propose once wishes are available — this section is read-only-by-
// default (collapsed), so nothing should require the user to first find
// and press a separate "suggest" button just to see it. `propose` calls
// the read-only `/api/plan/propose` endpoint — it persists nothing.
useEffect(() => {
if (!wishesLoading && wishes.length > 0 && proposal === null && !isProposing) {
void propose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wishesLoading, wishes.length, proposal])
return (
<details className="plan-fold" data-testid="plan-fold">
<summary className="plan-fold__summary" data-testid="plan-fold-summary">
{t('dashboard.planFold.summary')}
</summary>
<div className="plan-fold__content">
{!wishesLoading && wishes.length === 0 && (
<p className="plan-fold__hint" data-testid="plan-fold-no-wishes">
{t('dashboard.planFold.noWishesHint')}
</p>
)}
{proposalError && (
<div className="alert alert--error" role="alert" data-testid="plan-fold-proposal-error">
{proposalError}
</div>
)}
{proposal && (
<>
<section className="plan-fold__section" data-testid="plan-fold-overview">
<h4>{t('planResults.yearOverviewTitle')}</h4>
{proposal.outcomes.length === 0 ? (
<p>{t('planResults.noWindowsInHorizon')}</p>
) : (
<table className="plan-overview-table" data-testid="plan-fold-overview-table">
<thead>
<tr>
<th>{t('planResults.yearHeader')}</th>
<th>{t('planResults.leavesHeader')}</th>
</tr>
</thead>
<tbody>
{groupByYear(proposal.outcomes).map(({ year, outcomes }) => (
<tr key={year} data-testid={`plan-fold-overview-year-${year}`}>
<td>{year}</td>
<td>
{outcomes.map((outcome) => (
<span key={outcome.wishId} className="plan-overview-badge">
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}
: {outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')}
</span>
))}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{proposal.outcomes.some((o) => o.shortfallDays > 0) && (
<section className="plan-fold__section" data-testid="plan-fold-shortfalls">
<h4>{t('planResults.shortfallsTitle')}</h4>
<ul>
{proposal.outcomes
.filter((outcome) => outcome.shortfallDays > 0)
.map((outcome) => (
<li
key={`${outcome.wishId}-${outcome.year}`}
data-testid={`plan-fold-shortfall-${outcome.wishId}-${outcome.year}`}
>
<strong>
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}{' '}
{outcome.year}
</strong>
<p>{t('planResults.shortfallLine', { shortfall_days: outcome.shortfallDays })}</p>
{outcome.remedies.length > 0 && (
<ul data-testid={`plan-fold-remedies-${outcome.wishId}-${outcome.year}`}>
{outcome.remedies.map((remedy, idx) => (
<li key={`${remedy.kind}-${idx}`} data-testid={`plan-fold-remedy-${outcome.wishId}-${outcome.year}-${idx}`}>
{renderRemedyLine(remedy, t)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</section>
)}
{proposal.surplusAdvice.length > 0 && (
<section className="plan-fold__section" data-testid="plan-fold-surplus-advice">
<h4>{t('planResults.surplusAdviceTitle')}</h4>
<p>{t('planResults.surplusAdviceIntro')}</p>
<ul>
{proposal.surplusAdvice.map((advice, idx) => {
const titleKey = SURPLUS_ADVICE_KEYS[advice.kind]
return (
<li key={`${advice.kind}-${idx}`} data-testid={`plan-fold-surplus-advice-${idx}`}>
<strong>{titleKey ? t(titleKey) : advice.kind}</strong>
<p>{renderSurplusAdviceBody(advice, t, nameFor)}</p>
</li>
)
})}
</ul>
</section>
)}
<section className="plan-fold__section" data-testid="plan-fold-balances">
<h4>{t('planResults.balancesTitle')}</h4>
<table className="plan-balances-table" data-testid="plan-fold-pool-balances">
<thead>
<tr>
<th>{t('planResults.parentHeader')}</th>
<th>{t('planResults.sjukpenningLeftHeader')}</th>
<th>{t('planResults.lagstaLeftHeader')}</th>
<th>{t('planResults.dubbeldagarLeftHeader')}</th>
</tr>
</thead>
<tbody>
{proposal.poolBalances.map((pool) => (
<tr key={`${pool.parentPersonalDataUuid}-${pool.childId}`}>
<td>{nameFor(pool.parentPersonalDataUuid)}</td>
<td>{pool.sjukpenningLeft}</td>
<td>{pool.lagstaLeft}</td>
<td>{pool.dubbeldagarLeft}</td>
</tr>
))}
</tbody>
</table>
<table className="plan-balances-table" data-testid="plan-fold-vacation-balances">
<thead>
<tr>
<th>{t('planResults.parentHeader')}</th>
<th>{t('planResults.yearHeader')}</th>
<th>{t('planResults.usedHeader')}</th>
<th>{t('planResults.totalAvailableHeader')}</th>
</tr>
</thead>
<tbody>
{proposal.vacationBalances.map((budget) => (
<tr key={`${budget.parentPersonalDataUuid}-${budget.year}`}>
<td>{nameFor(budget.parentPersonalDataUuid)}</td>
<td>{budget.year}</td>
<td>{budget.alreadyUsed}</td>
<td>{budget.totalDays + budget.savedDaysIn}</td>
</tr>
))}
</tbody>
</table>
</section>
</>
)}
</div>
</details>
)
}
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SurplusAdviceTip } from './SurplusAdviceTip'
import { makeSurplusAdvice } from '../../../tests/fixtures'
const nameFor = (uuid: string) => (uuid === 'pd-b' ? 'Sam' : 'Okänd förälder')
describe('SurplusAdviceTip', () => {
it('renders a known kind with its translated title and body, resolving the parent name', () => {
render(
<SurplusAdviceTip
index={0}
nameFor={nameFor}
advice={makeSurplusAdvice({
kind: 'wish_more_leave',
messageKey: 'planSurplusAdvice.wish_more_leave',
daysInvolved: 12,
data: { parent_personal_data_uuid: 'pd-b', weeks: 2 },
})}
/>,
)
const tip = screen.getByTestId('dashboard-surplus-advice-0')
expect(tip).toHaveClass('tip-card', 'tip-card--action')
expect(tip).toHaveTextContent('Önska mer ledighet')
expect(tip).toHaveTextContent('Sam har råd med ytterligare 2 veckor ledigt (12 FP-dagar')
expect(tip).not.toHaveTextContent('pd-b')
})
it('leaves the parent name unresolved when the advice carries no uuid', () => {
render(
<SurplusAdviceTip
index={1}
nameFor={nameFor}
advice={makeSurplusAdvice({
kind: 'use_less_vacation',
messageKey: 'planSurplusAdvice.use_less_vacation',
daysInvolved: 3,
data: { years_text: '2027', min_vacation_days_per_year: 20 },
})}
/>,
)
const tip = screen.getByTestId('dashboard-surplus-advice-1')
expect(tip).toHaveTextContent('Spara färre semesterdagar')
expect(tip).toHaveTextContent('{parent_name} kan ta ut 3 färre semesterdagar (2027)')
})
it('falls back to the raw kind and messageKey for an unrecognised advice', () => {
render(
<SurplusAdviceTip
index={2}
nameFor={nameFor}
advice={makeSurplusAdvice({ kind: 'brand_new_kind', messageKey: 'planSurplusAdvice.brand_new_kind', data: {} })}
/>,
)
const tip = screen.getByTestId('dashboard-surplus-advice-2')
expect(tip).toHaveTextContent('brand_new_kind')
expect(tip).toHaveTextContent('planSurplusAdvice.brand_new_kind')
})
it('has no dismiss control', () => {
render(<SurplusAdviceTip index={0} nameFor={nameFor} advice={makeSurplusAdvice()} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,66 @@
import { ReactNode } from 'react'
import type { SurplusAdvice } from '@/types'
import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n'
// §9.2 surplus-advice kinds — `kind` -> title, `messageKey` -> the body
// template that interpolates the advice's own `data`. An unrecognised kind
// (a newer backend rule) falls back to the raw value rather than crashing.
const SURPLUS_ADVICE_TITLE_KEYS: Record<string, TranslationKey> = {
wish_more_leave: 'planResults.surplusAdviceWishMoreLeave',
shift_mix_to_fp: 'planResults.surplusAdviceShiftMixToFp',
front_load_early_years: 'planResults.surplusAdviceFrontLoadEarlyYears',
use_less_vacation: 'planResults.surplusAdviceUseLessVacation',
}
const SURPLUS_ADVICE_BODY_KEYS: Record<string, TranslationKey> = {
'planSurplusAdvice.wish_more_leave': 'planResults.surplusAdviceBodyWishMoreLeave',
'planSurplusAdvice.shift_mix_to_fp': 'planResults.surplusAdviceBodyShiftMixToFp',
'planSurplusAdvice.front_load_early_years': 'planResults.surplusAdviceBodyFrontLoadEarlyYears',
'planSurplusAdvice.use_less_vacation': 'planResults.surplusAdviceBodyUseLessVacation',
}
export interface SurplusAdviceTipProps {
advice: SurplusAdvice
/** Resolves a parent uuid to a display name (never a raw uuid). */
nameFor: (uuid: string) => string
/** Position in the advice list — only used for the test id. */
index: number
}
/**
* One plan-proposal surplus advice rendered in the same visual as a
* backend `TipCard` (same `tip-card` classes) so the dashboard's tips read
* as one list. Advice has no dismiss — it is derived from the plan and goes
* away when the plan changes.
*/
export function SurplusAdviceTip({ advice, nameFor, index }: SurplusAdviceTipProps): ReactNode {
const { t } = useTranslation()
const titleKey = SURPLUS_ADVICE_TITLE_KEYS[advice.kind]
const bodyKey = SURPLUS_ADVICE_BODY_KEYS[advice.messageKey]
const title = titleKey ? t(titleKey) : advice.kind
let body = advice.messageKey
if (bodyKey) {
const params = toTranslationParams(advice.data)
const uuid = advice.data.parent_personal_data_uuid
if (typeof uuid === 'string') {
params.parent_name = nameFor(uuid)
}
params.days_involved = advice.daysInvolved
body = t(bodyKey, params)
}
return (
<div
className="tip-card tip-card--action"
role="article"
aria-label={t('tip.aria', { title })}
data-testid={`dashboard-surplus-advice-${index}`}
>
<div className="tip-card__header">
<h4 className="tip-card__title">{title}</h4>
</div>
<p className="tip-card__body">{body}</p>
</div>
)
}
@@ -0,0 +1,372 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { YearRows, type YearRowsParent } from './YearRows'
import {
makeParentYearBalance,
makeParentalLeaveYearBalance,
makePlannedLeaveDay,
makeShortfallRemedy,
makeVacationYearBalance,
makeWindowOutcome,
} from '../../../tests/fixtures'
const HORIZON = { startDate: '2025-06-01', endDate: '2029-06-01' }
function balancesFor(uuid: string, years: number[], overrides: Partial<ReturnType<typeof makeParentYearBalance>> = {}) {
return years.map((year) =>
makeParentYearBalance({
personalDataUuid: uuid,
year,
vacation: makeVacationYearBalance({ year, ...(overrides.vacation ?? {}) }),
parentalLeave: makeParentalLeaveYearBalance({ year, ...(overrides.parentalLeave ?? {}) }),
}),
)
}
const YEARS = [2025, 2026, 2027, 2028, 2029]
const alex: YearRowsParent = {
personalDataUuid: 'pd-a',
name: 'Alex',
yearBalances: balancesFor('pd-a', YEARS, {
vacation: makeVacationYearBalance({ left: 12, carriedOut: 5, expiring: 0 }),
parentalLeave: makeParentalLeaveYearBalance({ sjukpenningLeft: 300, lagstaLeft: 12, sjukpenningUsed: 40, lagstaUsed: 3 }),
}),
}
const sam: YearRowsParent = {
personalDataUuid: 'pd-b',
name: 'Sam',
yearBalances: balancesFor('pd-b', YEARS, {
vacation: makeVacationYearBalance({ left: 20, carriedOut: 0, expiring: 3, isAssumed: false, earned: 30 }),
parentalLeave: makeParentalLeaveYearBalance({ sjukpenningLeft: 200, lagstaLeft: 40 }),
}),
}
function renderRows(props: Partial<Parameters<typeof YearRows>[0]> = {}) {
const onBandClick = vi.fn()
const utils = render(
<YearRows
parents={[alex, sam]}
plannedDays={[]}
outcomes={[]}
onBandClick={onBandClick}
{...HORIZON}
{...props}
/>,
)
return { ...utils, onBandClick }
}
describe('YearRows', () => {
describe('rows', () => {
it('renders one collapsed <details> row per year across the horizon', () => {
renderRows()
for (const year of YEARS) {
const row = screen.getByTestId(`year-row-${year}`)
expect(row.tagName).toBe('DETAILS')
expect(row).not.toHaveAttribute('open')
expect(row).toHaveClass('disclosure')
}
})
it('renders no rows when the horizon is inverted', () => {
renderRows({ startDate: '2029-06-01', endDate: '2025-06-01' })
expect(screen.queryByTestId(/^year-row-\d{4}$/)).not.toBeInTheDocument()
})
it('renders a legend with one entry per day type', () => {
renderRows()
const legend = screen.getByTestId('year-rows-legend')
expect(legend).toHaveTextContent('Sjukpenningnivå')
expect(legend).toHaveTextContent('Lägstanivå')
expect(legend).toHaveTextContent('Semester')
})
})
describe('per-parent figures (couple)', () => {
it('shows each parent\'s FP left (sjukpenning + lägsta) and vacation left · saved for the year', () => {
renderRows()
expect(screen.getByTestId('year-row-2026-fp-pd-a')).toHaveTextContent('FP kvar 312')
expect(screen.getByTestId('year-row-2026-vacation-pd-a')).toHaveTextContent('Semester kvar 12 · sparas 5')
expect(screen.getByTestId('year-row-2026-fp-pd-b')).toHaveTextContent('FP kvar 240')
expect(screen.getByTestId('year-row-2026-vacation-pd-b')).toHaveTextContent('Semester kvar 20 · sparas 0')
})
it('names each parent in the summary and the open table for a couple', () => {
renderRows()
const row = screen.getByTestId('year-row-2026')
expect(within(row).getByTestId('year-row-2026-parent-pd-a')).toHaveTextContent('Alex')
expect(within(row).getByTestId('year-row-2026-parent-pd-b')).toHaveTextContent('Sam')
const table = within(row).getByTestId('year-row-2026-table')
expect(within(table).getByRole('columnheader', { name: 'Alex' })).toBeInTheDocument()
expect(within(table).getByRole('columnheader', { name: 'Sam' })).toBeInTheDocument()
})
it('shows an expiring figure in the summary only when days actually expire', () => {
renderRows()
expect(screen.getByTestId('year-row-2026-expiring-pd-b')).toHaveTextContent('förfaller 3')
expect(screen.queryByTestId('year-row-2026-expiring-pd-a')).not.toBeInTheDocument()
})
it('renders a dash for a year the parent has no balance entry for', () => {
const partial: YearRowsParent = { ...alex, yearBalances: balancesFor('pd-a', [2025]) }
renderRows({ parents: [partial, sam] })
expect(screen.getByTestId('year-row-2027-fp-pd-a')).toHaveTextContent('FP kvar –')
expect(screen.getByTestId('year-row-2027-vacation-pd-a')).toHaveTextContent('Semester kvar –')
expect(screen.getByTestId('year-row-2027-vacation-pd-a')).not.toHaveTextContent('sparas')
const table = screen.getByTestId('year-row-2027-table')
expect(within(table).getAllByText('–').length).toBeGreaterThan(0)
})
it('gives the lanes self/partner caps and one lane per parent, self first', () => {
renderRows()
const laneA = screen.getByTestId('year-row-2026-lane-pd-a')
const laneB = screen.getByTestId('year-row-2026-lane-pd-b')
expect(laneA).toHaveClass('year-row__lane--self')
expect(laneB).toHaveClass('year-row__lane--partner')
expect(laneA.compareDocumentPosition(laneB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
})
describe('solo parent', () => {
it('omits the parent name and the table header, and gives the single lane no cap', () => {
renderRows({ parents: [alex] })
const row = screen.getByTestId('year-row-2026')
expect(within(row).getByTestId('year-row-2026-parent-pd-a')).not.toHaveTextContent('Alex')
expect(within(row).queryByRole('columnheader')).not.toBeInTheDocument()
const lane = screen.getByTestId('year-row-2026-lane-pd-a')
expect(lane).not.toHaveClass('year-row__lane--self')
expect(lane).not.toHaveClass('year-row__lane--partner')
})
})
describe('no parent projections yet', () => {
it('still draws every planned day in one anonymous lane, without figures or a table', () => {
renderRows({
parents: [],
plannedDays: [makePlannedLeaveDay({ id: '1', date: '2026-06-15', parentPersonalDataUuid: 'pd-a' })],
})
expect(screen.getByTestId('year-row-2026-lane-all')).toBeInTheDocument()
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
'title',
'Semester, 15 juni 2026 – 15 juni 2026 (1 dag). Klicka för att öppna i kalendern.',
)
expect(screen.queryByTestId('year-row-2026-fp-pd-a')).not.toBeInTheDocument()
expect(screen.queryByTestId('year-row-2026-table')).not.toBeInTheDocument()
})
})
describe('open body table', () => {
it('lists the parental-leave and vacation balances per parent', () => {
renderRows()
const table = screen.getByTestId('year-row-2026-table')
const rowFor = (label: string) => within(table).getByRole('row', { name: new RegExp(label) })
expect(rowFor('Använda i år · Sjukpenningnivå')).toHaveTextContent('40')
expect(rowFor('Använda i år · Lägstanivå')).toHaveTextContent('3')
expect(rowFor('Kvar vid årets slut · Sjukpenningnivå')).toHaveTextContent('300')
expect(rowFor('Kvar vid årets slut · Lägstanivå')).toHaveTextContent('12')
expect(rowFor('^Intjänade')).toHaveTextContent('25')
expect(rowFor('^Intjänade')).toHaveTextContent('30')
expect(rowFor('Sparade in')).toBeInTheDocument()
expect(rowFor('Planerade')).toBeInTheDocument()
expect(rowFor('^Kvar \\d')).toHaveTextContent('12')
expect(rowFor('Sparas till nästa år')).toHaveTextContent('5')
expect(rowFor('^Förfaller')).toHaveTextContent('3')
})
it('badges an assumed vacation entitlement and colours a non-zero expiring value', () => {
renderRows()
const table = screen.getByTestId('year-row-2026-table')
const earned = within(table).getByRole('row', { name: /^Intjänade/ })
// Alex's entitlement is the statutory default (assumed); Sam's was entered.
expect(within(earned).getAllByText('antaget')).toHaveLength(1)
const expiring = within(table).getByRole('row', { name: /^Förfaller/ })
const cells = within(expiring).getAllByRole('cell')
expect(cells[0]).not.toHaveClass('year-row__cell--danger')
expect(cells[1]).toHaveClass('year-row__cell--danger')
})
it('opens and closes as a native disclosure', async () => {
const user = userEvent.setup()
renderRows()
const row = screen.getByTestId('year-row-2026')
await user.click(row.querySelector('summary') as HTMLElement)
expect(row).toHaveAttribute('open')
await user.click(row.querySelector('summary') as HTMLElement)
expect(row).not.toHaveAttribute('open')
})
})
describe('bands', () => {
it('groups a contiguous run of one parent\'s planned days into a single band in that parent\'s lane', () => {
renderRows({
plannedDays: [
makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation', parentPersonalDataUuid: 'pd-a' }),
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'vacation', parentPersonalDataUuid: 'pd-a' }),
makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'sjukpenning', parentPersonalDataUuid: 'pd-a' }),
],
})
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(1)
const band = screen.getByTestId('year-timeline-band-2026-0')
expect(band).toHaveAttribute(
'title',
'Alex · Semester, 15 juni 2026 – 17 juni 2026 (3 dagar). Klicka för att öppna i kalendern.',
)
expect(band).toHaveClass('year-row__band--vacation')
expect(screen.getByTestId('year-row-2026-lane-pd-a')).toContainElement(band)
})
it('numbers bands across lanes in lane order (self first)', () => {
renderRows({
plannedDays: [
makePlannedLeaveDay({ id: '1', date: '2026-08-01', dayType: 'lagsta', parentPersonalDataUuid: 'pd-b' }),
makePlannedLeaveDay({ id: '2', date: '2026-01-05', dayType: 'sjukpenning', parentPersonalDataUuid: 'pd-a' }),
],
})
expect(screen.getByTestId('year-row-2026-lane-pd-a')).toContainElement(screen.getByTestId('year-timeline-band-2026-0'))
expect(screen.getByTestId('year-row-2026-lane-pd-b')).toContainElement(screen.getByTestId('year-timeline-band-2026-1'))
expect(screen.getByTestId('year-timeline-band-2026-1')).toHaveClass('year-row__band--lagsta')
})
it('splits into separate bands across a gap bigger than a bridged weekend', () => {
renderRows({
plannedDays: [
makePlannedLeaveDay({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'pd-a' }),
makePlannedLeaveDay({ id: '2', date: '2026-06-15', parentPersonalDataUuid: 'pd-a' }),
],
})
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(2)
})
it('never lets a band span a calendar-year boundary', () => {
renderRows({
plannedDays: [
makePlannedLeaveDay({ id: '1', date: '2026-12-30', parentPersonalDataUuid: 'pd-a' }),
makePlannedLeaveDay({ id: '2', date: '2027-01-02', parentPersonalDataUuid: 'pd-a' }),
],
})
expect(screen.getByTestId('year-timeline-band-2026-0')).toBeInTheDocument()
expect(screen.getByTestId('year-timeline-band-2027-0')).toBeInTheDocument()
})
it('excludes planned days outside the horizon and days of an unknown parent', () => {
renderRows({
plannedDays: [
makePlannedLeaveDay({ id: '1', date: '2030-01-01', parentPersonalDataUuid: 'pd-a' }),
makePlannedLeaveDay({ id: '2', date: '2026-01-01', parentPersonalDataUuid: 'pd-zzz' }),
],
})
expect(screen.queryByTestId(/^year-timeline-band-/)).not.toBeInTheDocument()
})
it('breaks a tie between types in sjukpenning > lägsta > semester order', () => {
renderRows({
plannedDays: [
makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation', parentPersonalDataUuid: 'pd-a' }),
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'lagsta', parentPersonalDataUuid: 'pd-a' }),
],
})
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveClass('year-row__band--lagsta')
})
it('calls onBandClick with the band start date and does not toggle the row', async () => {
const user = userEvent.setup()
const { onBandClick } = renderRows({
plannedDays: [makePlannedLeaveDay({ id: '1', date: '2026-06-15', parentPersonalDataUuid: 'pd-a' })],
})
await user.click(screen.getByTestId('year-timeline-band-2026-0'))
expect(onBandClick).toHaveBeenCalledWith('2026-06-15')
expect(screen.getByTestId('year-row-2026')).not.toHaveAttribute('open')
})
})
describe('wish outcomes', () => {
it('renders nothing about wishes when there are no outcomes', () => {
renderRows()
expect(screen.queryByTestId('year-row-2026-wishes')).not.toBeInTheDocument()
})
it('lists the year\'s outcomes in start-date order under the table, fully funded ones without a shortfall', () => {
renderRows({
outcomes: [
makeWindowOutcome({ wishId: 'lw-2', wishName: 'Höstlov', year: 2026, startDate: '2026-10-26', weeksWished: 1, weeksFunded: 1 }),
makeWindowOutcome({ wishId: 'lw-1', wishName: 'Sommar', year: 2026, weeksWished: 6, weeksFunded: 6 }),
makeWindowOutcome({ wishId: 'lw-3', wishName: 'Sommar', year: 2027, weeksWished: 6, weeksFunded: 4, shortfallDays: 10 }),
],
})
const wishes = screen.getByTestId('year-row-2026-wishes')
const items = within(wishes).getAllByRole('listitem')
expect(items[0]).toHaveTextContent('Sommar')
expect(items[0]).toHaveTextContent('6 av 6 veckor')
expect(items[1]).toHaveTextContent('Höstlov')
expect(screen.queryByTestId('year-row-shortfall-lw-1-2026')).not.toBeInTheDocument()
expect(screen.queryByTestId('year-row-wish-lw-3-2026')).not.toBeInTheDocument()
expect(screen.getByTestId('year-row-wish-lw-3-2027')).toBeInTheDocument()
})
it('rounds fractional funded weeks to one decimal in the locale notation', () => {
renderRows({
outcomes: [makeWindowOutcome({ wishId: 'lw-1', year: 2026, weeksWished: 6, weeksFunded: 4.2857 })],
})
expect(screen.getByTestId('year-row-wish-lw-1-2026')).toHaveTextContent('4,3 av 6 veckor')
})
it('resolves a preset wish name through its message key', () => {
renderRows({
outcomes: [makeWindowOutcome({ wishId: 'lw-1', wishName: 'stale', wishNameMessageKey: 'wishEditor.seasonSommar', year: 2026 })],
})
expect(screen.getByTestId('year-row-wish-lw-1-2026')).not.toHaveTextContent('stale')
expect(screen.getByTestId('year-row-wish-lw-1-2026')).toHaveTextContent('Sommar')
})
it('renders the shortfall line and each remedy for a window that ran short', () => {
renderRows({
outcomes: [
makeWindowOutcome({
wishId: 'lw-1',
wishName: 'Sportlov',
year: 2027,
weeksWished: 2,
weeksFunded: 1,
shortfallDays: 2,
remedies: [
makeShortfallRemedy({
kind: 'carry_vacation_forward',
messageKey: 'planRemedies.carry_vacation_forward',
data: { spare_total: 5 },
daysFreed: 5,
}),
makeShortfallRemedy({
kind: 'lower_priority_wish',
messageKey: 'planRemedies.lower_priority_wish',
data: { wish_name: 'Höstlov', from_weeks: 2, to_weeks: 1 },
daysFreed: 4,
}),
makeShortfallRemedy({ kind: 'novel', messageKey: 'planRemedies.novel', data: {}, daysFreed: 1 }),
],
}),
],
})
const wish = screen.getByTestId('year-row-wish-lw-1-2027')
expect(wish).toHaveTextContent('1 av 2 veckor')
expect(screen.getByTestId('year-row-shortfall-lw-1-2027')).toHaveTextContent('2 dag(ar) kunde inte finansieras')
expect(screen.getByTestId('year-row-remedy-lw-1-2027-0')).toHaveTextContent(
'5 semesterdagar från tidigare år kan sparas framåt',
)
expect(screen.getByTestId('year-row-remedy-lw-1-2027-1')).toHaveTextContent(
'Sänk Höstlov från 2 till 1 veckor frigör 4 dagar',
)
expect(screen.getByTestId('year-row-remedy-lw-1-2027-2')).toHaveTextContent('planRemedies.novel')
})
it('shows the shortfall line without a remedy list when the engine has none', () => {
renderRows({
outcomes: [makeWindowOutcome({ wishId: 'lw-1', year: 2026, shortfallDays: 3, remedies: [] })],
})
expect(screen.getByTestId('year-row-shortfall-lw-1-2026')).toBeInTheDocument()
expect(screen.queryByTestId('year-row-remedy-lw-1-2026-0')).not.toBeInTheDocument()
})
})
})
@@ -0,0 +1,463 @@
import { MouseEvent, ReactNode } from 'react'
import type { ParentYearBalance, PlannedLeaveDay, ShortfallRemedy, WindowOutcome } from '@/types'
import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
import { getLabelForType, getTypeKey } from '@/utils/leaveTypes'
import { formatDateForDisplay } from '@/utils/dates'
import { resolveWishName } from '@/utils/wishName'
type Translate = (key: TranslationKey, params?: TranslationParams) => string
/** One parent's lane in the rows — already resolved to a display name
* (never a raw uuid) and ordered self-first by the caller. */
export interface YearRowsParent {
personalDataUuid: string
name: string
/** `Age4Projection.yearBalances` for this parent. */
yearBalances: ParentYearBalance[]
}
export interface YearRowsProps {
parents: YearRowsParent[]
plannedDays: PlannedLeaveDay[]
/** ISO-8601 date — horizon start (the child's birth date). */
startDate: string
/** ISO-8601 date — horizon end (the child's 4th birthday). */
endDate: string
/** The plan proposal's per-wish outcomes (empty when there are no wishes). */
outcomes: WindowOutcome[]
/** Called with a band's ISO-8601 start date when clicked. */
onBandClick: (date: string) => void
}
interface Band {
startDate: string
endDate: string
dominantType: PlannedLeaveDay['dayType']
/** Planned days actually in the band — a bridged weekend inside it is not one. */
dayCount: number
}
interface Lane {
personalDataUuid: string | null
name: string | null
bands: Band[]
}
// A run of planned days separates into a new band once the gap between
// consecutive dates exceeds this many days — bridges a weekend (2 days) or
// a weekend plus a single röd dag (3 days) without splitting one leave
// period into several bands.
const GAP_DAYS = 3
// Legend order, and the tie-break order when a band's days split evenly.
const TYPE_ORDER: PlannedLeaveDay['dayType'][] = ['sjukpenning', 'lagsta', 'vacation']
// The shortfall remedies `_build_remedies` (allocation_service.py) puts on
// the wire: `messageKey` -> the body template interpolating the remedy's
// own `data`.
const REMEDY_BODY_KEYS: Record<string, TranslationKey> = {
'planRemedies.reduce_preschool_reserve': 'planResults.remedyBodyReducePreschoolReserve',
'planRemedies.carry_vacation_forward': 'planResults.remedyBodyCarryVacationForward',
'planRemedies.lower_priority_wish': 'planResults.remedyBodyLowerPriorityWish',
'planRemedies.shift_window_into_free_days': 'planResults.remedyBodyShiftWindowIntoFreeDays',
}
function toUtcDate(iso: string): Date {
return new Date(iso + 'T00:00:00Z')
}
function dayOfYearUtc(d: Date): number {
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1)
return Math.floor((d.getTime() - yearStart) / 86_400_000) + 1
}
function daysInYear(year: number): number {
return Math.floor((Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / 86_400_000)
}
function dominantType(days: PlannedLeaveDay[]): PlannedLeaveDay['dayType'] {
const counts: Record<string, number> = {}
for (const d of days) {
counts[d.dayType] = (counts[d.dayType] ?? 0) + 1
}
let best = TYPE_ORDER[0]!
let bestCount = -1
for (const type of TYPE_ORDER) {
const count = counts[type] ?? 0
if (count > bestCount) {
bestCount = count
best = type
}
}
return best
}
/** Groups sorted planned days into contiguous bands (one leave period each),
* never spanning a calendar-year boundary — each year gets its own row. */
function buildBands(plannedDays: PlannedLeaveDay[], startDate: string, endDate: string): Band[] {
const start = toUtcDate(startDate)
const end = toUtcDate(endDate)
const inHorizon = plannedDays
.filter((d) => {
const date = toUtcDate(d.date)
return date >= start && date <= end
})
.sort((a, b) => a.date.localeCompare(b.date))
const bands: Band[] = []
let current: PlannedLeaveDay[] = []
const closeCurrent = () => {
if (current.length === 0) return
bands.push({
startDate: current[0]!.date,
endDate: current[current.length - 1]!.date,
dominantType: dominantType(current),
dayCount: current.length,
})
current = []
}
for (const day of inHorizon) {
if (current.length === 0) {
current.push(day)
continue
}
const prev = toUtcDate(current[current.length - 1]!.date)
const next = toUtcDate(day.date)
const gapDays = Math.round((next.getTime() - prev.getTime()) / 86_400_000)
const crossesYear = prev.getUTCFullYear() !== next.getUTCFullYear()
if (gapDays > GAP_DAYS || crossesYear) {
closeCurrent()
}
current.push(day)
}
closeCurrent()
return bands
}
/** One lane per parent (self first, as given); with no parent projections
* yet, a single anonymous lane carries every planned day so the timeline
* still shows the plan. */
function buildLanes(parents: YearRowsParent[], plannedDays: PlannedLeaveDay[], startDate: string, endDate: string): Lane[] {
if (parents.length === 0) {
return [{ personalDataUuid: null, name: null, bands: buildBands(plannedDays, startDate, endDate) }]
}
return parents.map((parent) => ({
personalDataUuid: parent.personalDataUuid,
name: parent.name,
bands: buildBands(
plannedDays.filter((d) => d.parentPersonalDataUuid === parent.personalDataUuid),
startDate,
endDate,
),
}))
}
function renderRemedyLine(remedy: ShortfallRemedy, t: Translate): string {
const key = REMEDY_BODY_KEYS[remedy.messageKey]
if (!key) return remedy.messageKey
const params = toTranslationParams(remedy.data)
params.days_freed = remedy.daysFreed
return t(key, params)
}
/**
* One disclosure row per calendar year from the child's birth to their 4th
* birthday: the year, a thin per-parent timeline of planned leave (click a
* band to open the calendar there), and each parent's "what is left at the
* end of this year" figures for both parental-leave days and vacation.
* Opening a row shows the full per-parent balance table for that year plus
* the plan proposal's wish outcomes.
*/
export function YearRows({ parents, plannedDays, startDate, endDate, outcomes, onBandClick }: YearRowsProps): ReactNode {
const { t, locale } = useTranslation()
const startYear = toUtcDate(startDate).getUTCFullYear()
const endYear = toUtcDate(endDate).getUTCFullYear()
const years = startYear <= endYear ? Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i) : []
const lanes = buildLanes(parents, plannedDays, startDate, endDate)
const couple = parents.length > 1
const balanceFor = (parent: YearRowsParent, year: number): ParentYearBalance | undefined =>
parent.yearBalances.find((b) => b.year === year)
// The engine reports funded weeks as a fraction of the wish (4.29 of 6);
// one decimal in the locale's own notation is all a glance needs.
const formatWeeks = (weeks: number): string =>
weeks.toLocaleString(locale === 'sv' ? 'sv-SE' : 'en-GB', { maximumFractionDigits: 1 })
const handleBandClick = (event: MouseEvent<HTMLButtonElement>, date: string) => {
// The band sits inside the row's <summary>; cancelling the click keeps
// the row from toggling on top of the navigation.
event.preventDefault()
onBandClick(date)
}
return (
<div className="year-rows" data-testid="year-rows">
<ul className="year-rows__legend" data-testid="year-rows-legend">
{TYPE_ORDER.map((type) => (
<li key={type} className="year-rows__legend-item">
<span className={`year-rows__swatch year-rows__swatch--${getTypeKey(type)}`} aria-hidden="true" />
{getLabelForType(type, t)}
</li>
))}
</ul>
{years.map((year) => {
const yearLength = daysInYear(year)
const outcomesForYear = outcomes
.filter((o) => o.year === year)
.sort((a, b) => a.startDate.localeCompare(b.startDate))
let bandIndex = 0
return (
<details key={year} className="disclosure year-row" data-testid={`year-row-${year}`}>
<summary className="disclosure__summary year-row__summary">
<span className="year-row__year tnum">{year}</span>
<span className={couple ? 'year-row__track year-row__track--couple' : 'year-row__track'}>
{lanes.map((lane, laneIndex) => (
<span
key={lane.personalDataUuid ?? 'all'}
className={`year-row__lane${couple ? (laneIndex === 0 ? ' year-row__lane--self' : ' year-row__lane--partner') : ''}`}
data-testid={`year-row-${year}-lane-${lane.personalDataUuid ?? 'all'}`}
>
{lane.bands
.filter((band) => toUtcDate(band.startDate).getUTCFullYear() === year)
.map((band) => {
const idx = bandIndex++
const left = ((dayOfYearUtc(toUtcDate(band.startDate)) - 1) / yearLength) * 100
const width =
((dayOfYearUtc(toUtcDate(band.endDate)) - dayOfYearUtc(toUtcDate(band.startDate)) + 1) / yearLength) * 100
const text = t(band.dayCount === 1 ? 'yearRow.bandLabelOneDay' : 'yearRow.bandLabel', {
dayType: getLabelForType(band.dominantType, t),
startDate: formatDateForDisplay(band.startDate, locale),
endDate: formatDateForDisplay(band.endDate, locale),
dayCount: band.dayCount,
})
const label = lane.name ? `${lane.name} · ${text}` : text
return (
<button
key={`${year}-${idx}`}
type="button"
className={`year-row__band year-row__band--${getTypeKey(band.dominantType)}`}
style={{ left: `${left}%`, width: `${width}%` }}
onClick={(event) => handleBandClick(event, band.startDate)}
data-testid={`year-timeline-band-${year}-${idx}`}
title={label}
aria-label={label}
/>
)
})}
</span>
))}
</span>
{parents.length > 0 && (
<span className="year-row__figures">
{parents.map((parent) => {
const balance = balanceFor(parent, year)
return (
<span
key={parent.personalDataUuid}
className="year-row__parent"
data-testid={`year-row-${year}-parent-${parent.personalDataUuid}`}
>
{couple && <span className="year-row__name">{parent.name}</span>}
<span className="year-row__figure" data-testid={`year-row-${year}-fp-${parent.personalDataUuid}`}>
<span className="year-row__figure-label">{t('yearRow.fpLeft')}</span>{' '}
<span className="year-row__figure-value tnum">
{balance ? balance.parentalLeave.sjukpenningLeft + balance.parentalLeave.lagstaLeft : '–'}
</span>
</span>
<span
className="year-row__figure"
data-testid={`year-row-${year}-vacation-${parent.personalDataUuid}`}
>
<span className="year-row__figure-label">{t('yearRow.vacationLeft')}</span>{' '}
<span className="year-row__figure-value tnum">{balance ? balance.vacation.left : '–'}</span>
{balance && (
<span className="year-row__figure-sub tnum">
{' · '}
{t('yearRow.carriedOut', { days: balance.vacation.carriedOut })}
</span>
)}
{balance && balance.vacation.expiring > 0 && (
<span
className="year-row__figure-danger tnum"
data-testid={`year-row-${year}-expiring-${parent.personalDataUuid}`}
>
{' · '}
{t('yearRow.expiring', { days: balance.vacation.expiring })}
</span>
)}
</span>
</span>
)
})}
</span>
)}
</summary>
<div className="disclosure__body year-row__body">
{parents.length > 0 && (
<table className="year-row__table" data-testid={`year-row-${year}-table`}>
{couple && (
<thead>
<tr>
<th scope="col" />
{parents.map((parent) => (
<th key={parent.personalDataUuid} scope="col" className="year-row__cell--num">
{parent.name}
</th>
))}
</tr>
</thead>
)}
<tbody>
<tr className="year-row__group">
<th scope="rowgroup" colSpan={parents.length + 1}>
{t('yearRow.parentalLeaveTitle')}
</th>
</tr>
<BalanceRow
label={`${t('yearRow.usedThisYear')} · ${getLabelForType('sjukpenning', t)}`}
parents={parents}
year={year}
pick={(b) => b.parentalLeave.sjukpenningUsed}
/>
<BalanceRow
label={`${t('yearRow.usedThisYear')} · ${getLabelForType('lagsta', t)}`}
parents={parents}
year={year}
pick={(b) => b.parentalLeave.lagstaUsed}
/>
<BalanceRow
label={`${t('yearRow.leftAtYearEnd')} · ${getLabelForType('sjukpenning', t)}`}
parents={parents}
year={year}
pick={(b) => b.parentalLeave.sjukpenningLeft}
emphasis
/>
<BalanceRow
label={`${t('yearRow.leftAtYearEnd')} · ${getLabelForType('lagsta', t)}`}
parents={parents}
year={year}
pick={(b) => b.parentalLeave.lagstaLeft}
emphasis
/>
<tr className="year-row__group">
<th scope="rowgroup" colSpan={parents.length + 1}>
{t('yearRow.vacationTitle')}
</th>
</tr>
<BalanceRow
label={t('yearRow.earned')}
parents={parents}
year={year}
pick={(b) => b.vacation.earned}
badge={(b) => (b.vacation.isAssumed ? t('yearRow.assumed') : null)}
/>
<BalanceRow label={t('yearRow.carriedIn')} parents={parents} year={year} pick={(b) => b.vacation.carriedIn} />
<BalanceRow label={t('yearRow.plannedUse')} parents={parents} year={year} pick={(b) => b.vacation.plannedUse} />
<BalanceRow label={t('yearRow.left')} parents={parents} year={year} pick={(b) => b.vacation.left} emphasis />
<BalanceRow
label={t('yearRow.carriedOutRow')}
parents={parents}
year={year}
pick={(b) => b.vacation.carriedOut}
/>
<BalanceRow
label={t('yearRow.expiringRow')}
parents={parents}
year={year}
pick={(b) => b.vacation.expiring}
danger={(b) => b.vacation.expiring > 0}
/>
</tbody>
</table>
)}
{outcomesForYear.length > 0 && (
<div className="year-row__wishes" data-testid={`year-row-${year}-wishes`}>
<h4 className="year-row__wishes-title">{t('yearRow.wishesTitle')}</h4>
<ul className="year-row__wish-list">
{outcomesForYear.map((outcome) => (
<li
key={outcome.wishId}
className="year-row__wish"
data-testid={`year-row-wish-${outcome.wishId}-${year}`}
>
<span className="year-row__wish-name">
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}
</span>
<span className={outcome.shortfallDays > 0 ? 'year-row__wish-weeks year-row__wish-weeks--short' : 'year-row__wish-weeks'}>
{t('yearRow.weeksFunded', {
funded: formatWeeks(outcome.weeksFunded),
wished: formatWeeks(outcome.weeksWished),
})}
</span>
{outcome.shortfallDays > 0 && (
<span className="year-row__shortfall" data-testid={`year-row-shortfall-${outcome.wishId}-${year}`}>
{t('planResults.shortfallLine', { shortfall_days: outcome.shortfallDays })}
</span>
)}
{outcome.shortfallDays > 0 && outcome.remedies.length > 0 && (
<ul className="year-row__remedies">
{outcome.remedies.map((remedy, idx) => (
<li key={`${remedy.kind}-${idx}`} data-testid={`year-row-remedy-${outcome.wishId}-${year}-${idx}`}>
{renderRemedyLine(remedy, t)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
)}
</div>
</details>
)
})}
</div>
)
}
interface BalanceRowProps {
label: string
parents: YearRowsParent[]
year: number
pick: (balance: ParentYearBalance) => number
/** Optional small badge text after the value (e.g. "antaget"). */
badge?: (balance: ParentYearBalance) => string | null
/** Danger ink on the value when true for that parent. */
danger?: (balance: ParentYearBalance) => boolean
/** A "what is left" row — heavier value weight. */
emphasis?: boolean
}
function BalanceRow({ label, parents, year, pick, badge, danger, emphasis }: BalanceRowProps): ReactNode {
return (
<tr className={emphasis ? 'year-row__row year-row__row--emphasis' : 'year-row__row'}>
<th scope="row" className="year-row__cell-label">
{label}
</th>
{parents.map((parent) => {
const balance = parent.yearBalances.find((b) => b.year === year)
const badgeText = balance && badge ? badge(balance) : null
const isDanger = balance && danger ? danger(balance) : false
return (
<td
key={parent.personalDataUuid}
className={isDanger ? 'year-row__cell--num year-row__cell--danger tnum' : 'year-row__cell--num tnum'}
>
{badgeText && <span className="badge badge--muted year-row__badge">{badgeText}</span>}
{balance ? pick(balance) : '–'}
</td>
)
})}
</tr>
)
}
@@ -1,133 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { YearTimeline } from './YearTimeline'
import { makePlannedLeaveDay } from '../../../tests/fixtures'
describe('YearTimeline', () => {
it('renders one row per year across the horizon', () => {
render(
<YearTimeline
plannedDays={[]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
for (const year of [2025, 2026, 2027, 2028, 2029]) {
expect(screen.getByTestId(`year-timeline-row-${year}`)).toBeInTheDocument()
}
})
it('groups a contiguous run of planned days into a single band', () => {
render(
<YearTimeline
plannedDays={[
makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation' }),
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'vacation' }),
makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'sjukpenning' }),
]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
// Three consecutive days -> exactly one band for 2026, dominant type
// vacation (2 of 3 days).
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(1)
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
'title',
'Semester, 15 juni 2026 – 17 juni 2026 (3 dagar). Klicka för att öppna denna månad i kalendern.',
)
})
it('splits into separate bands across a gap bigger than a bridged weekend', () => {
render(
<YearTimeline
plannedDays={[
makePlannedLeaveDay({ id: '1', date: '2026-01-05', dayType: 'vacation' }),
makePlannedLeaveDay({ id: '2', date: '2026-06-15', dayType: 'vacation' }),
]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(2)
})
it('never lets a band span a calendar-year boundary', () => {
render(
<YearTimeline
plannedDays={[
makePlannedLeaveDay({ id: '1', date: '2026-12-30', dayType: 'vacation' }),
makePlannedLeaveDay({ id: '2', date: '2027-01-02', dayType: 'vacation' }),
]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
expect(screen.getByTestId('year-timeline-band-2026-0')).toBeInTheDocument()
expect(screen.getByTestId('year-timeline-band-2027-0')).toBeInTheDocument()
})
it('excludes planned days outside the horizon', () => {
render(
<YearTimeline
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2030-01-01', dayType: 'vacation' })]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
expect(screen.queryByTestId(/^year-timeline-band-/)).not.toBeInTheDocument()
})
it('calls onBandClick with the band start date when clicked', async () => {
const user = userEvent.setup()
const onBandClick = vi.fn()
render(
<YearTimeline
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'sjukpenning' })]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={onBandClick}
/>,
)
await user.click(screen.getByTestId('year-timeline-band-2026-0'))
expect(onBandClick).toHaveBeenCalledWith('2026-06-15')
})
it('renders an explanatory caption below the title', () => {
render(
<YearTimeline plannedDays={[]} startDate="2025-06-01" endDate="2029-06-01" onBandClick={vi.fn()} />,
)
expect(screen.getByText(/Varje rad är ett år/)).toBeInTheDocument()
})
it('renders a colour legend with one entry per day type', () => {
render(
<YearTimeline plannedDays={[]} startDate="2025-06-01" endDate="2029-06-01" onBandClick={vi.fn()} />,
)
const legend = screen.getByTestId('year-timeline-legend')
expect(legend).toHaveTextContent('Sjukpenningnivå')
expect(legend).toHaveTextContent('Lägstanivå')
expect(legend).toHaveTextContent('Semester')
})
it('includes the day count in a band\'s label', () => {
render(
<YearTimeline
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation' })]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
'title',
'Semester, 15 juni 2026 – 15 juni 2026 (1 dag). Klicka för att öppna denna månad i kalendern.',
)
})
})
@@ -1,183 +0,0 @@
import { ReactNode } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import { getColorForType, getLabelForType, getTypeModifier } from '@/utils/leaveTypes'
import { formatDateForDisplay } from '@/utils/dates'
export interface YearTimelineProps {
plannedDays: PlannedLeaveDay[]
/** ISO-8601 date — horizon start (the child's birth date). */
startDate: string
/** ISO-8601 date — horizon end (the child's 4th birthday). */
endDate: string
/** Called with a band's ISO-8601 start date when clicked. */
onBandClick: (date: string) => void
}
interface Band {
startDate: string
endDate: string
dominantType: PlannedLeaveDay['dayType']
/** Number of planned days actually in this band (not the calendar span —
* a bridged weekend/röd dag inside the band isn't a planned day). */
dayCount: number
}
// A run of planned days separates into a new band once the gap between
// consecutive dates exceeds this many days — bridges a weekend (2 days) or
// a weekend plus a single röd dag (3 days) without splitting one leave
// period into several bands.
const GAP_DAYS = 3
// Tie-break order when a band's days split evenly across types.
const TYPE_PRIORITY: PlannedLeaveDay['dayType'][] = ['sjukpenning', 'lagsta', 'vacation']
function toUtcDate(iso: string): Date {
return new Date(iso + 'T00:00:00Z')
}
function dayOfYearUtc(d: Date): number {
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1)
return Math.floor((d.getTime() - yearStart) / 86_400_000) + 1
}
function daysInYear(year: number): number {
return Math.floor((Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / 86_400_000)
}
function dominantType(days: PlannedLeaveDay[]): PlannedLeaveDay['dayType'] {
const counts: Record<string, number> = {}
for (const d of days) {
counts[d.dayType] = (counts[d.dayType] ?? 0) + 1
}
let best = TYPE_PRIORITY[0]!
let bestCount = -1
for (const type of TYPE_PRIORITY) {
const count = counts[type] ?? 0
if (count > bestCount) {
bestCount = count
best = type
}
}
return best
}
/** Groups sorted planned days into contiguous bands (one leave period each),
* never spanning a calendar-year boundary — each year gets its own row. */
function buildBands(plannedDays: PlannedLeaveDay[], startDate: string, endDate: string): Band[] {
const start = toUtcDate(startDate)
const end = toUtcDate(endDate)
const inHorizon = plannedDays
.filter((d) => {
const date = toUtcDate(d.date)
return date >= start && date <= end
})
.sort((a, b) => a.date.localeCompare(b.date))
const bands: Band[] = []
let current: PlannedLeaveDay[] = []
const closeCurrent = () => {
if (current.length === 0) return
bands.push({
startDate: current[0]!.date,
endDate: current[current.length - 1]!.date,
dominantType: dominantType(current),
dayCount: current.length,
})
current = []
}
for (const day of inHorizon) {
if (current.length === 0) {
current.push(day)
continue
}
const prev = toUtcDate(current[current.length - 1]!.date)
const next = toUtcDate(day.date)
const gapDays = Math.round((next.getTime() - prev.getTime()) / 86_400_000)
const crossesYear = prev.getUTCFullYear() !== next.getUTCFullYear()
if (gapDays > GAP_DAYS || crossesYear) {
closeCurrent()
}
current.push(day)
}
closeCurrent()
return bands
}
/**
* One row per calendar year from the child's birth to their 4th birthday,
* with a colored band per contiguous planned-leave period ("what do the
* next four years look like"). Clicking a band navigates to the calendar at
* that date — the reason this view exists alongside (not instead of) the
* per-parent day bars above it.
*/
export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: YearTimelineProps): ReactNode {
const { t, locale } = useTranslation()
const start = toUtcDate(startDate)
const end = toUtcDate(endDate)
const startYear = start.getUTCFullYear()
const endYear = end.getUTCFullYear()
const years = startYear <= endYear
? Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i)
: []
const bands = buildBands(plannedDays, startDate, endDate)
return (
<div data-testid="year-timeline" className="year-timeline">
<h3 className="year-timeline__title">{t('yearTimeline.title')}</h3>
<p className="year-timeline__caption">{t('yearTimeline.caption')}</p>
<ul className="year-timeline__legend" data-testid="year-timeline-legend">
{TYPE_PRIORITY.map((type) => (
<li key={type} className="year-timeline__legend-item">
<span
className={`year-timeline__legend-swatch year-timeline__legend-swatch--${getTypeModifier(type)}`}
style={{ backgroundColor: getColorForType(type) }}
aria-hidden="true"
/>
{getLabelForType(type, t)}
</li>
))}
</ul>
{years.map((year) => {
const yearLength = daysInYear(year)
const bandsForYear = bands.filter((b) => toUtcDate(b.startDate).getUTCFullYear() === year)
return (
<div key={year} className="year-timeline__row" data-testid={`year-timeline-row-${year}`}>
<span className="year-timeline__year-label">{year}</span>
<div className="year-timeline__track">
{bandsForYear.map((band, idx) => {
const left = ((dayOfYearUtc(toUtcDate(band.startDate)) - 1) / yearLength) * 100
const width = ((dayOfYearUtc(toUtcDate(band.endDate)) - dayOfYearUtc(toUtcDate(band.startDate)) + 1) / yearLength) * 100
const label = t(
band.dayCount === 1 ? 'yearTimeline.bandLabelOneDay' : 'yearTimeline.bandLabel',
{
dayType: getLabelForType(band.dominantType, t),
startDate: formatDateForDisplay(band.startDate, locale),
endDate: formatDateForDisplay(band.endDate, locale),
dayCount: band.dayCount,
},
)
return (
<button
key={`${year}-${idx}`}
type="button"
className={`year-timeline__band year-timeline__band--${getTypeModifier(band.dominantType)}`}
style={{ left: `${left}%`, width: `${width}%`, backgroundColor: getColorForType(band.dominantType) }}
onClick={() => onBandClick(band.startDate)}
data-testid={`year-timeline-band-${year}-${idx}`}
title={label}
aria-label={label}
/>
)
})}
</div>
</div>
)
})}
</div>
)
}