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