Make the overview answer the year-by-year question

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-20 06:30:46 +02:00
parent 4b802ba0be
commit 80b4f1cb52
30 changed files with 2444 additions and 3477 deletions
@@ -1,328 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { RedDaysList } from './RedDaysList'
import type { CalendarDayEntry, DayOverride } from '@/types'
const baseDays: CalendarDayEntry[] = [
{
date: '2026-01-01',
name: 'Nyårsdagen',
dayType: 'red_day',
isOverride: false,
overrideIsWorkingDay: null,
isFree: true,
},
{
date: '2026-04-05',
name: 'Påskdagen; Söndag',
dayType: 'red_day',
isOverride: false,
overrideIsWorkingDay: null,
isFree: true,
},
{
date: '2026-12-24',
name: null,
dayType: 'working_day',
isOverride: true,
overrideIsWorkingDay: false,
isFree: true,
},
{
date: '2027-01-06',
name: 'Trettondedag jul',
dayType: 'red_day',
isOverride: false,
overrideIsWorkingDay: null,
isFree: true,
},
{
date: '2026-05-04',
name: null,
dayType: 'working_day',
isOverride: false,
overrideIsWorkingDay: null,
isFree: true,
},
]
const baseOverrides: DayOverride[] = [
{
id: 'do-2026-12-24',
workspaceId: 'ws-1',
date: '2026-12-24',
isWorkingDay: false,
note: null,
createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-01T00:00:00',
},
]
describe('RedDaysList', () => {
const defaultProps = {
days: baseDays,
startYear: 2026,
years: 4,
minYear: 2026,
maxYear: 2035,
onPrevWindow: vi.fn(),
onNextWindow: vi.fn(),
overrides: baseOverrides,
workspaceId: 'ws-1',
onUpsertOverride: vi.fn().mockResolvedValue(undefined),
onRemoveOverride: vi.fn().mockResolvedValue(undefined),
}
it('renders one year group per year in the window, including years with no matches', () => {
render(<RedDaysList {...defaultProps} />)
expect(screen.getByTestId('red-days-year-2026')).toBeInTheDocument()
expect(screen.getByTestId('red-days-year-2027')).toBeInTheDocument()
expect(screen.getByTestId('red-days-year-2028')).toBeInTheDocument()
expect(screen.getByTestId('red-days-year-2029')).toBeInTheDocument()
expect(screen.queryByTestId('red-days-year-2030')).not.toBeInTheDocument()
})
it('shows statutory red days with a cleaned-up name', () => {
render(<RedDaysList {...defaultProps} />)
expect(screen.getByTestId('red-days-item-2026-01-01')).toHaveTextContent('Nyårsdagen')
// "; Söndag" is stripped, not dumped raw
const easter = screen.getByTestId('red-days-item-2026-04-05')
expect(easter).toHaveTextContent('Påskdagen')
expect(easter).not.toHaveTextContent('Söndag')
})
it('shows a workspace override even on a non-red day', () => {
render(<RedDaysList {...defaultProps} />)
const overrideItem = screen.getByTestId('red-days-item-2026-12-24')
expect(overrideItem).toBeInTheDocument()
expect(screen.getByTestId('red-days-override-badge-2026-12-24')).toBeInTheDocument()
})
it('shows an empty-year message for a year with no red days or overrides', () => {
render(<RedDaysList {...defaultProps} />)
const year2028 = screen.getByTestId('red-days-year-2028')
expect(year2028).toHaveTextContent(/Inga röda dagar eller undantag/)
})
it('skips a plain working day that is neither a red day nor an override', () => {
render(<RedDaysList {...defaultProps} />)
// 2026-05-04 is a plain working_day, non-override entry in baseDays — it
// must not show up as a list item under its year.
expect(screen.queryByTestId('red-days-item-2026-05-04')).not.toBeInTheDocument()
})
it('labels a working-day override distinctly from a free-day override', () => {
const workingOverrideDays = [
{
date: '2026-03-02',
name: null,
dayType: 'working_day' as const,
isOverride: true,
overrideIsWorkingDay: true,
isFree: false,
},
]
render(<RedDaysList {...defaultProps} days={workingOverrideDays} />)
const badge = screen.getByTestId('red-days-override-badge-2026-03-02')
expect(badge).toHaveClass('red-days-list__override-badge--working')
expect(badge).toHaveTextContent('Undantag: arbetsdag')
})
it('shows the effective-working label for a day that is not free', () => {
const workingDays = [
{
date: '2026-03-02',
name: null,
dayType: 'working_day' as const,
isOverride: true,
overrideIsWorkingDay: true,
isFree: false,
},
]
render(<RedDaysList {...defaultProps} days={workingDays} />)
const item = screen.getByTestId('red-days-item-2026-03-02')
expect(item.querySelector('.red-days-list__effective--working')).toBeInTheDocument()
expect(item.querySelector('.red-days-list__effective--free')).not.toBeInTheDocument()
})
it('disables the previous button at minYear', () => {
render(<RedDaysList {...defaultProps} startYear={2026} minYear={2026} />)
expect(screen.getByTestId('red-days-prev')).toBeDisabled()
})
it('disables the next button when the window reaches maxYear', () => {
render(<RedDaysList {...defaultProps} startYear={2032} maxYear={2035} />)
expect(screen.getByTestId('red-days-next')).toBeDisabled()
})
it('enables both buttons in the middle of the range', () => {
render(<RedDaysList {...defaultProps} startYear={2029} minYear={2026} maxYear={2035} />)
expect(screen.getByTestId('red-days-prev')).not.toBeDisabled()
expect(screen.getByTestId('red-days-next')).not.toBeDisabled()
})
it('calls onPrevWindow / onNextWindow when the pagination buttons are clicked', () => {
const onPrevWindow = vi.fn()
const onNextWindow = vi.fn()
// Neither button disabled: startYear sits strictly between minYear and maxYear.
render(
<RedDaysList
{...defaultProps}
startYear={2029}
minYear={2026}
maxYear={2035}
onPrevWindow={onPrevWindow}
onNextWindow={onNextWindow}
/>,
)
screen.getByTestId('red-days-prev').click()
screen.getByTestId('red-days-next').click()
expect(onPrevWindow).toHaveBeenCalledTimes(1)
expect(onNextWindow).toHaveBeenCalledTimes(1)
})
it('uses the actual endYear from the backend instead of startYear + years - 1 when the window was clamped', () => {
// Regression test: the backend clamps a window whose requested end
// exceeds the cached range (e.g. paging to the last page) and reports
// the ACTUAL end year via `endYear`. Without honoring it, this component
// would render extra year headers (2036, 2037) the backend never
// returned data for, showing a false "no red days" for years that are
// simply outside the cache rather than genuinely holiday-free.
render(
<RedDaysList
{...defaultProps}
startYear={2034}
endYear={2035}
minYear={2026}
maxYear={2035}
/>,
)
expect(screen.getByTestId('red-days-range')).toHaveTextContent('2034–2035')
expect(screen.queryByTestId('red-days-year-2036')).not.toBeInTheDocument()
expect(screen.queryByTestId('red-days-year-2037')).not.toBeInTheDocument()
})
it('shows a loading message and hides year groups while loading', () => {
render(<RedDaysList {...defaultProps} isLoading={true} />)
expect(screen.getByText(/Laddar röda dagar/)).toBeInTheDocument()
expect(screen.queryByTestId('red-days-year-2026')).not.toBeInTheDocument()
})
it('shows an error message and hides year groups on error', () => {
render(<RedDaysList {...defaultProps} error="Something broke" />)
expect(screen.getByRole('alert')).toHaveTextContent('Something broke')
expect(screen.queryByTestId('red-days-year-2026')).not.toBeInTheDocument()
})
describe('toggling a day (§Task8)', () => {
it('clicking a statutory red day upserts an override making that exact date a working day', async () => {
const onUpsertOverride = vi.fn().mockResolvedValue(undefined)
render(<RedDaysList {...defaultProps} onUpsertOverride={onUpsertOverride} />)
fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01'))
await waitFor(() => {
expect(onUpsertOverride).toHaveBeenCalledWith({
workspaceId: 'ws-1',
date: '2026-01-01',
isWorkingDay: true,
note: null,
})
})
})
it('clicking an overridden day back to its statutory value removes the override by id', async () => {
const onRemoveOverride = vi.fn().mockResolvedValue(undefined)
render(<RedDaysList {...defaultProps} onRemoveOverride={onRemoveOverride} />)
// 2026-12-24 carries the override from `baseOverrides` (id: do-2026-12-24).
fireEvent.click(screen.getByTestId('red-days-toggle-2026-12-24'))
await waitFor(() => {
expect(onRemoveOverride).toHaveBeenCalledWith('do-2026-12-24')
})
})
it('only touches the exact date clicked, not the same month/day in another year', async () => {
const sameMonthDayAcrossYears: CalendarDayEntry[] = [
{
date: '2026-01-01',
name: 'Nyårsdagen',
dayType: 'red_day',
isOverride: false,
overrideIsWorkingDay: null,
isFree: true,
},
{
date: '2027-01-01',
name: 'Nyårsdagen',
dayType: 'red_day',
isOverride: false,
overrideIsWorkingDay: null,
isFree: true,
},
]
const onUpsertOverride = vi.fn().mockResolvedValue(undefined)
render(
<RedDaysList
{...defaultProps}
days={sameMonthDayAcrossYears}
overrides={[]}
onUpsertOverride={onUpsertOverride}
/>,
)
fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01'))
await waitFor(() => {
expect(onUpsertOverride).toHaveBeenCalledTimes(1)
})
expect(onUpsertOverride).toHaveBeenCalledWith({
workspaceId: 'ws-1',
date: '2026-01-01',
isWorkingDay: true,
note: null,
})
// The 2027 New Year's Day button was never touched by the 2026 click.
expect(screen.getByTestId('red-days-toggle-2027-01-01')).toHaveTextContent('Ledig')
})
it('disables the clicked button while the request is in flight', async () => {
let resolveUpsert: () => void = () => {}
const onUpsertOverride = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveUpsert = resolve
}),
)
render(<RedDaysList {...defaultProps} onUpsertOverride={onUpsertOverride} />)
const button = screen.getByTestId('red-days-toggle-2026-01-01')
fireEvent.click(button)
expect(button).toBeDisabled()
resolveUpsert()
await waitFor(() => expect(button).not.toBeDisabled())
})
it('surfaces a failed toggle instead of swallowing it', async () => {
const onUpsertOverride = vi.fn().mockRejectedValue(new Error('network blew up'))
render(<RedDaysList {...defaultProps} onUpsertOverride={onUpsertOverride} />)
fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01'))
await waitFor(() => {
expect(screen.getByTestId('red-days-toggle-error')).toHaveTextContent('network blew up')
})
})
it('is keyboard accessible with an aria-label naming the date and action', () => {
render(<RedDaysList {...defaultProps} />)
const button = screen.getByTestId('red-days-toggle-2026-01-01')
expect(button.tagName).toBe('BUTTON')
expect(button).toHaveAccessibleName(/2026|januari/i)
})
})
})
@@ -1,204 +0,0 @@
/**
* §3.8 — a per-year list of röda dagar (statutory red days) plus the
* workspace's own day overrides, four years at a time, paginated across the
* full cached range using `minYear`/`maxYear` from `GET /api/calendar/days`.
*
* Product-owner decision: show BOTH the statutory red days AND the user's
* day overrides (worked/off) — not just the statutory calendar — so the
* list matches what the app actually treats as free.
*/
import { ReactNode, useMemo, useState } from 'react'
import type { CalendarDayEntry, DayOverride, DayOverrideCreateRequest } from '@/types'
import { formatDateForDisplay, formatHolidayName } from '@/utils/dates'
import { useTranslation } from '@/i18n'
export interface RedDaysListProps {
/** All days for the current window (any day_type); the component filters to red days + overrides. */
days: CalendarDayEntry[]
startYear: number
years?: number
/**
* The window's ACTUAL end year, as reported by `GET /api/calendar/days`
* (`CalendarWindow.endYear`). The backend clamps its window to the cached
* range, so the last page can span fewer than `years` years — pass this
* through so the header/year-groups never claim years the backend didn't
* return (which would otherwise render as false "no red days" for years
* past the cache, e.g. once paging reaches `maxYear`). Falls back to the
* naive `startYear + years - 1` when omitted, e.g. before the first fetch
* resolves and there's no response to read it from yet.
*/
endYear?: number | undefined
minYear: number | null
maxYear: number | null
isLoading?: boolean
error?: string | null
onPrevWindow: () => void
onNextWindow: () => void
/** The workspace's own day overrides — used to find an existing override's id so a click back to the statutory value can `remove` it rather than leave a redundant row. Not scoped to the visible window: dates are absolute, so this can be the full workspace list. */
overrides: DayOverride[]
workspaceId: string
/** Creates/replaces the override for one absolute date — never affects the same month/day in another year. */
onUpsertOverride: (req: DayOverrideCreateRequest) => Promise<void>
/** Deletes an existing override by id, reverting that one date to its statutory value. */
onRemoveOverride: (id: string) => Promise<void>
}
export function RedDaysList({
days,
startYear,
years = 4,
endYear: endYearProp,
minYear,
maxYear,
isLoading = false,
error = null,
onPrevWindow,
onNextWindow,
overrides,
workspaceId,
onUpsertOverride,
onRemoveOverride,
}: RedDaysListProps): ReactNode {
const { t } = useTranslation()
const endYear = endYearProp ?? startYear + years - 1
const [pendingDate, setPendingDate] = useState<string | null>(null)
const [toggleError, setToggleError] = useState<string | null>(null)
const overrideIdByDate = useMemo(() => {
const map = new Map<string, string>()
for (const override of overrides) {
map.set(override.date, override.id)
}
return map
}, [overrides])
const handleToggle = async (day: CalendarDayEntry) => {
setToggleError(null)
setPendingDate(day.date)
try {
if (day.isOverride) {
// Any existing override, in either direction, reverts to the
// statutory value on click — there is no third state.
const overrideId = overrideIdByDate.get(day.date)
if (!overrideId) {
setToggleError(t('redDaysList.toggleError'))
return
}
await onRemoveOverride(overrideId)
} else {
// Only statutory red days (always effectively free) reach this
// branch — see the year-group filter below — so the flip is always
// "make this one date a working day".
await onUpsertOverride({ workspaceId, date: day.date, isWorkingDay: true, note: null })
}
} catch (err) {
setToggleError(err instanceof Error ? err.message : t('redDaysList.toggleError'))
} finally {
setPendingDate(null)
}
}
const byYear = useMemo(() => {
const map = new Map<number, CalendarDayEntry[]>()
for (let y = startYear; y <= endYear; y++) {
map.set(y, [])
}
for (const day of days) {
if (day.dayType !== 'red_day' && !day.isOverride) continue
const year = Number(day.date.slice(0, 4))
const list = map.get(year)
if (list) list.push(day)
}
for (const list of map.values()) {
list.sort((a, b) => a.date.localeCompare(b.date))
}
return map
}, [days, startYear, endYear])
const prevDisabled = minYear !== null && startYear <= minYear
const nextDisabled = maxYear !== null && endYear >= maxYear
return (
<div className="red-days-list" data-testid="red-days-list">
<div className="red-days-list__pagination">
<button
type="button"
onClick={onPrevWindow}
disabled={prevDisabled}
data-testid="red-days-prev"
className="btn btn--secondary btn--sm"
>
{t('redDaysList.prevWindow')}
</button>
<span className="red-days-list__range" data-testid="red-days-range">
{startYear}–{endYear}
</span>
<button
type="button"
onClick={onNextWindow}
disabled={nextDisabled}
data-testid="red-days-next"
className="btn btn--secondary btn--sm"
>
{t('redDaysList.nextWindow')}
</button>
</div>
{isLoading && <p className="red-days-list__loading">{t('redDaysList.loading')}</p>}
{error && (
<p className="red-days-list__error" role="alert">
{error}
</p>
)}
{toggleError && (
<p className="red-days-list__toggle-error" role="alert" data-testid="red-days-toggle-error">
{toggleError}
</p>
)}
{!isLoading &&
!error &&
Array.from(byYear.entries()).map(([year, items]) => (
<div key={year} className="red-days-list__year" data-testid={`red-days-year-${year}`}>
<h3 className="red-days-list__year-title">{year}</h3>
{items.length === 0 ? (
<p className="red-days-list__empty">{t('redDaysList.emptyYear')}</p>
) : (
<ul className="red-days-list__items">
{items.map((day) => (
<li key={day.date} className="red-days-list__item" data-testid={`red-days-item-${day.date}`}>
<span className="red-days-list__date">{formatDateForDisplay(day.date)}</span>
{day.dayType === 'red_day' && day.name && (
<span className="red-days-list__name">{formatHolidayName(day.name)}</span>
)}
{day.isOverride && (
<span
className={`badge badge--${day.overrideIsWorkingDay ? 'danger' : 'accent'} red-days-list__override-badge red-days-list__override-badge--${day.overrideIsWorkingDay ? 'working' : 'free'}`}
data-testid={`red-days-override-badge-${day.date}`}
>
{day.overrideIsWorkingDay ? t('redDaysList.overrideWorking') : t('redDaysList.overrideFree')}
</span>
)}
<button
type="button"
className={`red-days-list__effective red-days-list__effective--${day.isFree ? 'free' : 'working'}`}
onClick={() => handleToggle(day)}
disabled={pendingDate === day.date}
aria-label={
day.isFree
? t('redDaysList.toggleToWorkingAria', { date: formatDateForDisplay(day.date) })
: t('redDaysList.toggleToFreeAria', { date: formatDateForDisplay(day.date) })
}
data-testid={`red-days-toggle-${day.date}`}
>
{day.isFree ? t('redDaysList.effectiveFree') : t('redDaysList.effectiveWorking')}
</button>
</li>
))}
</ul>
)}
</div>
))}
</div>
)
}
@@ -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>
)
}
@@ -1,282 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { DayOverridesInput } from './DayOverridesInput'
describe('DayOverridesInput', () => {
const mockOverrides = [
{
id: 'do-1',
workspaceId: 'ws-1',
date: '2026-12-24',
isWorkingDay: true,
note: 'Jobbade på juldagen',
createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-01T00:00:00',
},
{
id: 'do-2',
workspaceId: 'ws-1',
date: '2026-06-20',
isWorkingDay: false,
note: null,
createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-01T00:00:00',
},
]
let mockOnAdd: ReturnType<typeof vi.fn>
let mockOnRemove: ReturnType<typeof vi.fn>
beforeEach(() => {
mockOnAdd = vi.fn().mockResolvedValue(undefined)
mockOnRemove = vi.fn().mockResolvedValue(undefined)
})
describe('rendering', () => {
it('renders list of existing overrides', () => {
render(
<DayOverridesInput
overrides={mockOverrides}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
expect(screen.getByText('24 december 2026')).toBeInTheDocument()
expect(screen.getByText('20 juni 2026')).toBeInTheDocument()
})
it('renders working day override with correct status', () => {
render(
<DayOverridesInput
overrides={mockOverrides}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
expect(screen.getByTestId('override-item-do-1').textContent).toContain('Jag jobbar den här dagen')
})
it('renders off day override with correct status', () => {
render(
<DayOverridesInput
overrides={mockOverrides}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
expect(screen.getByTestId('override-item-do-2').textContent).toContain('Jag är ledig den här dagen')
})
it('renders note when present', () => {
render(
<DayOverridesInput
overrides={mockOverrides}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
expect(screen.getByText('Anteckning: Jobbade på juldagen')).toBeInTheDocument()
})
it('renders empty state when no overrides', () => {
render(
<DayOverridesInput
overrides={[]}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
expect(screen.getByText('Du har inga undantag i kalendern ännu.')).toBeInTheDocument()
})
it('renders add form with all fields', () => {
render(
<DayOverridesInput
overrides={[]}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
expect(screen.getByTestId('override-date-input')).toBeInTheDocument()
expect(screen.getByTestId('override-working-radio')).toBeInTheDocument()
expect(screen.getByTestId('override-off-radio')).toBeInTheDocument()
expect(screen.getByTestId('override-note-input')).toBeInTheDocument()
expect(screen.getByTestId('add-override-button')).toBeInTheDocument()
})
})
describe('adding override', () => {
it('calls onAdd with correct payload when working day is selected', async () => {
render(
<DayOverridesInput
overrides={[]}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
fireEvent.change(screen.getByTestId('override-date-input'), { target: { value: '2026-12-24' } })
fireEvent.click(screen.getByTestId('override-working-radio'))
fireEvent.change(screen.getByTestId('override-note-input'), { target: { value: 'Jobbar denna dag' } })
fireEvent.click(screen.getByTestId('add-override-button'))
await waitFor(() => {
expect(mockOnAdd).toHaveBeenCalledWith({
workspaceId: 'ws-1',
date: '2026-12-24',
isWorkingDay: true,
note: 'Jobbar denna dag',
})
})
})
it('calls onAdd with correct payload when off day is selected', async () => {
render(
<DayOverridesInput
overrides={[]}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
fireEvent.change(screen.getByTestId('override-date-input'), { target: { value: '2026-06-20' } })
fireEvent.click(screen.getByTestId('override-off-radio'))
fireEvent.click(screen.getByTestId('add-override-button'))
await waitFor(() => {
expect(mockOnAdd).toHaveBeenCalledWith({
workspaceId: 'ws-1',
date: '2026-06-20',
isWorkingDay: false,
note: null,
})
})
})
it('clears form after successful add', async () => {
render(
<DayOverridesInput
overrides={[]}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
const dateInput = screen.getByTestId('override-date-input') as HTMLInputElement
const noteInput = screen.getByTestId('override-note-input') as HTMLInputElement
fireEvent.change(dateInput, { target: { value: '2026-12-24' } })
fireEvent.change(noteInput, { target: { value: 'Test note' } })
fireEvent.click(screen.getByTestId('add-override-button'))
await waitFor(() => {
expect(dateInput.value).toBe('')
expect(noteInput.value).toBe('')
})
})
it('disables submit button when date is empty', () => {
render(
<DayOverridesInput
overrides={[]}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
const submitButton = screen.getByTestId('add-override-button') as HTMLButtonElement
expect(submitButton.disabled).toBe(true)
})
it('does not call onAdd when the form is submitted with no date entered', async () => {
render(
<DayOverridesInput
overrides={[]}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
// Submit the form directly (bypassing the disabled submit button) to
// exercise the handler's own empty-date guard.
fireEvent.submit(screen.getByTestId('add-override-form'))
await waitFor(() => {
expect(mockOnAdd).not.toHaveBeenCalled()
})
})
})
describe('removing override', () => {
it('calls onRemove with correct id', async () => {
render(
<DayOverridesInput
overrides={mockOverrides}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
fireEvent.click(screen.getByTestId('remove-override-do-1'))
await waitFor(() => {
expect(mockOnRemove).toHaveBeenCalledWith('do-1')
})
})
it('swallows an onRemove failure instead of throwing (handled by the parent)', async () => {
mockOnRemove.mockRejectedValueOnce(new Error('delete failed'))
render(
<DayOverridesInput
overrides={mockOverrides}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
fireEvent.click(screen.getByTestId('remove-override-do-1'))
await waitFor(() => {
expect(mockOnRemove).toHaveBeenCalledWith('do-1')
})
// The item is still rendered — the component didn't crash on the rejection.
expect(screen.getByTestId('override-item-do-1')).toBeInTheDocument()
})
})
describe('loading state', () => {
it('disables form during loading', () => {
render(
<DayOverridesInput
overrides={[]}
isLoading={true}
onAdd={mockOnAdd}
onRemove={mockOnRemove}
workspaceId="ws-1"
/>
)
expect((screen.getByTestId('override-date-input') as HTMLInputElement).disabled).toBe(true)
expect((screen.getByTestId('add-override-button') as HTMLButtonElement).disabled).toBe(true)
})
})
})
@@ -1,178 +0,0 @@
import { ReactNode, useState } from 'react'
import { formatDateForDisplay } from '@/utils/dates'
import { useTranslation } from '@/i18n'
import type { DayOverride, DayOverrideCreateRequest } from '@/types'
export interface DayOverridesInputProps {
overrides: DayOverride[]
isLoading?: boolean
onAdd: (req: DayOverrideCreateRequest) => Promise<void>
onRemove: (id: string) => Promise<void>
workspaceId: string
}
export function DayOverridesInput({
overrides,
isLoading = false,
onAdd,
onRemove,
workspaceId,
}: DayOverridesInputProps): ReactNode {
const { t, locale } = useTranslation()
const [date, setDate] = useState('')
const [isWorkingDay, setIsWorkingDay] = useState(true)
const [note, setNote] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleAddOverride = async (e: React.FormEvent) => {
e.preventDefault()
if (!date) return
try {
setIsSubmitting(true)
await onAdd({
workspaceId,
date,
isWorkingDay,
note: note || null,
})
setDate('')
setIsWorkingDay(true)
setNote('')
} finally {
setIsSubmitting(false)
}
}
const handleRemove = async (id: string) => {
try {
await onRemove(id)
} catch {
// Error handling is done in parent
}
}
const sortedOverrides = [...overrides].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
return (
<div data-testid="day-overrides-input" className="day-overrides-input">
{overrides.length === 0 ? (
<div className="day-overrides-input__empty">
<p>{t('dayOverridesInput.empty')}</p>
</div>
) : (
<div className="day-overrides-input__list">
<ul className="day-overrides-input__items">
{sortedOverrides.map((override) => (
<li key={override.id} className="day-overrides-input__item" data-testid={`override-item-${override.id}`}>
<div className="day-overrides-input__item-header">
<div className="day-overrides-input__item-date">
<strong>{formatDateForDisplay(override.date, locale)}</strong>
</div>
<div className="day-overrides-input__item-status">
<span className={`day-overrides-input__status day-overrides-input__status--${override.isWorkingDay ? 'working' : 'off'}`}>
{override.isWorkingDay ? t('dayOverridesInput.workingStatus') : t('dayOverridesInput.offStatus')}
</span>
</div>
</div>
{override.note && (
<div className="day-overrides-input__item-note">
{t('dayOverridesInput.noteLabel', { note: override.note })}
</div>
)}
<button
type="button"
onClick={() => handleRemove(override.id)}
disabled={isSubmitting || isLoading}
className="btn btn--danger btn--sm btn--block"
data-testid={`remove-override-${override.id}`}
>
{t('common.delete')}
</button>
</li>
))}
</ul>
</div>
)}
<form onSubmit={handleAddOverride} className="day-overrides-input__form" data-testid="add-override-form">
<h3>{t('dayOverridesInput.addSectionTitle')}</h3>
<div className="field day-overrides-input__form-group">
<label htmlFor="override-date" className="field__label">
{t('dayOverridesInput.dateLabel')}
</label>
<input
id="override-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
disabled={isSubmitting || isLoading}
data-testid="override-date-input"
className="field__control"
/>
</div>
<div className="day-overrides-input__form-group">
<fieldset className="day-overrides-input__radio-group">
<legend>{t('dayOverridesInput.statusLegend')}</legend>
<label className="day-overrides-input__radio-label">
<input
type="radio"
name="status"
value="working"
checked={isWorkingDay}
onChange={() => setIsWorkingDay(true)}
disabled={isSubmitting || isLoading}
data-testid="override-working-radio"
/>
<span>{t('dayOverridesInput.workingStatus')}</span>
</label>
<label className="day-overrides-input__radio-label">
<input
type="radio"
name="status"
value="off"
checked={!isWorkingDay}
onChange={() => setIsWorkingDay(false)}
disabled={isSubmitting || isLoading}
data-testid="override-off-radio"
/>
<span>{t('dayOverridesInput.offStatus')}</span>
</label>
</fieldset>
</div>
<div className="field day-overrides-input__form-group">
<label htmlFor="override-note" className="field__label">
{t('dayOverridesInput.noteFieldLabel')}
</label>
<input
id="override-note"
type="text"
placeholder={t('dayOverridesInput.notePlaceholder')}
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={280}
disabled={isSubmitting || isLoading}
data-testid="override-note-input"
className="field__control"
/>
<small className="day-overrides-input__char-count">
{t('dayOverridesInput.charCount', { count: note.length })}
</small>
</div>
<button
type="submit"
disabled={isSubmitting || isLoading || !date}
className="btn btn--primary btn--block"
data-testid="add-override-button"
>
{isSubmitting ? t('dayOverridesInput.addingButton') : t('dayOverridesInput.addButton')}
</button>
</form>
</div>
)
}
@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useParentalLeaves } from './useParentalLeaves'
import * as api from '@/services/api'
import { makeParentalLeaveFetch } from '../../tests/fixtures'
vi.mock('@/services/api')
const flush = () =>
act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
describe('useParentalLeaves', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts empty and does not fetch without a child or members', () => {
const { result } = renderHook(() => useParentalLeaves([], null))
expect(result.current.data).toEqual({})
expect(result.current.isLoading).toBe(false)
expect(api.getParentalLeave).not.toHaveBeenCalled()
})
it('does not fetch when there are members but no child', () => {
renderHook(() => useParentalLeaves(['pd-a'], null))
expect(api.getParentalLeave).not.toHaveBeenCalled()
})
it('fetches one snapshot per member and keys the result by uuid, keeping null for a parent with none', async () => {
const alex = makeParentalLeaveFetch({ personalDataUuid: 'pd-a' })
vi.mocked(api.getParentalLeave).mockImplementation(async (uuid) => (uuid === 'pd-a' ? alex : null))
const { result } = renderHook(() => useParentalLeaves(['pd-a', 'pd-b'], 'child-1'))
await flush()
expect(api.getParentalLeave).toHaveBeenCalledWith('pd-a', 'child-1')
expect(api.getParentalLeave).toHaveBeenCalledWith('pd-b', 'child-1')
expect(result.current.data).toEqual({ 'pd-a': alex, 'pd-b': null })
expect(result.current.isLoading).toBe(false)
expect(result.current.error).toBeNull()
})
it('surfaces a fetch error and clears the map', async () => {
vi.mocked(api.getParentalLeave).mockRejectedValue(new Error('Fetch failed'))
const { result } = renderHook(() => useParentalLeaves(['pd-a'], 'child-1'))
await flush()
expect(result.current.error).toBe('Fetch failed')
expect(result.current.data).toEqual({})
})
it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.getParentalLeave).mockRejectedValue('boom')
const { result } = renderHook(() => useParentalLeaves(['pd-a'], 'child-1'))
await flush()
expect(result.current.error).toBe('Failed to fetch parental leave data')
})
it('refetches when the membership changes, not on a mere array identity change', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
const { rerender } = renderHook(({ uuids }: { uuids: string[] }) => useParentalLeaves(uuids, 'child-1'), {
initialProps: { uuids: ['pd-a'] },
})
await flush()
expect(api.getParentalLeave).toHaveBeenCalledTimes(1)
rerender({ uuids: ['pd-a'] })
await flush()
expect(api.getParentalLeave).toHaveBeenCalledTimes(1)
rerender({ uuids: ['pd-a', 'pd-b'] })
await flush()
expect(api.getParentalLeave).toHaveBeenCalledTimes(3)
})
it('clears the map when the child identity is lost', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue(makeParentalLeaveFetch())
const { result, rerender } = renderHook(
({ childId }: { childId: string | null }) => useParentalLeaves(['pd-a'], childId),
{ initialProps: { childId: 'child-1' as string | null } },
)
await flush()
expect(Object.keys(result.current.data)).toEqual(['pd-a'])
rerender({ childId: null })
await flush()
expect(result.current.data).toEqual({})
})
it('exposes refresh, which fetches again', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
const { result } = renderHook(() => useParentalLeaves(['pd-a'], 'child-1'))
await flush()
await act(async () => {
await result.current.refresh()
})
expect(api.getParentalLeave).toHaveBeenCalledTimes(2)
})
it('ignores a stale response that lands after a newer request started', async () => {
let resolveFirst: (value: null) => void = () => undefined
vi.mocked(api.getParentalLeave)
.mockImplementationOnce(
() =>
new Promise<null>((resolve) => {
resolveFirst = resolve
}),
)
.mockResolvedValue(makeParentalLeaveFetch({ personalDataUuid: 'pd-b' }))
const { result, rerender } = renderHook(({ uuids }: { uuids: string[] }) => useParentalLeaves(uuids, 'child-1'), {
initialProps: { uuids: ['pd-a'] },
})
rerender({ uuids: ['pd-b'] })
await flush()
expect(Object.keys(result.current.data)).toEqual(['pd-b'])
await act(async () => {
resolveFirst(null)
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(Object.keys(result.current.data)).toEqual(['pd-b'])
})
})
+73
View File
@@ -0,0 +1,73 @@
import { useState, useEffect, useCallback } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { ParentalLeaveFetch } from '@/types'
/** uuid → that parent's latest balance snapshot, or `null` when nothing has
* been saved for them yet. A uuid that is absent (not `null`) has not been
* fetched yet — callers that gate an "estimated" hint must treat the two
* differently. */
export type ParentalLeaveByMember = Record<string, ParentalLeaveFetch | null>
interface UseParentalLeavesResult {
data: ParentalLeaveByMember
isLoading: boolean
error: string | null
refresh: () => Promise<void>
}
/**
* `useParentalLeave` for every member of a workspace at once — one
* `GET /api/parental-leave/{uuid}/{childId}` per member, in parallel. Feeds
* the dashboard's per-parent balance snapshot, where a single-parent fetch
* left the partner's figures invisible.
*
* Deliberately not exported from the `@/hooks` barrel: `Dashboard.tsx`
* imports it directly so its page test can mock this one module without
* touching the barrel's automock.
*/
export function useParentalLeaves(memberUuids: string[], childId: string | null): UseParentalLeavesResult {
const [data, setData] = useState<ParentalLeaveByMember>({})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
// A fresh array identity every render would re-run the effect below on
// every render; the joined key only changes when the membership does.
const memberKey = memberUuids.join('\n')
const fetch = useCallback(async () => {
const requestId = start()
const uuids = memberKey === '' ? [] : memberKey.split('\n')
if (!childId || uuids.length === 0) {
// Identity gone (logout, no child yet) — clear rather than keep the
// previous account's figures around until new ids arrive.
setData({})
setError(null)
return
}
setIsLoading(true)
setError(null)
try {
const results = await Promise.all(uuids.map((uuid) => api.getParentalLeave(uuid, childId)))
if (!isCurrent(requestId)) return
const next: ParentalLeaveByMember = {}
uuids.forEach((uuid, index) => {
next[uuid] = results[index] ?? null
})
setData(next)
} catch (err) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
setData({})
} finally {
if (isCurrent(requestId)) setIsLoading(false)
}
}, [memberKey, childId, start, isCurrent])
useEffect(() => {
void fetch()
}, [fetch])
return { data, isLoading, error, refresh: fetch }
}
+1 -1
View File
@@ -109,7 +109,7 @@ describe('LanguageContext', () => {
expect(
translate('en', 'parentalLeaveBreakdown.reservedNote', { reserved: 90, transferable: 45 }),
).toBe(
'90 of your days are reserved and cannot be transferred to the other parent (up to 45 days can be transferred).',
'90 of each parent\'s days are reserved and cannot be transferred to the other parent (up to 45 days can be transferred).',
)
})
+33 -22
View File
@@ -2,44 +2,55 @@ export const dashboard = {
'age4Bars.expiringStat': '{expiring} days expiring',
'age4Bars.savableStat': '{left} of {cap} days can be saved',
'age4Bars.title': 'Days left at age 4, per parent',
'dashboard.balancePickerPrompt': 'Whose balance is this?',
'dashboard.balanceLink': 'Update balances',
'dashboard.daysLeft': 'Days left',
'dashboard.enterManually': 'Enter details manually',
'dashboard.estimatedBadge': 'Estimated',
'dashboard.estimatedBadgeCaption': 'No real balances have been fetched yet — these figures use the statutory assumption (480 days, none used). Fetch from Försäkringskassan or enter details manually for exact figures.',
'dashboard.estimatedBadgeCaption': 'No real balances have been fetched yet — these figures assume 480 days, none used. Update balances under Settings.',
'dashboard.expiringAtB4': 'Expiring at age 4 if the current plan is followed (all parents)',
'dashboard.expiringAtB4ForParent': 'Expiring at age 4 if the current plan is followed – {name}',
'dashboard.expiringAtB4ParentFallback': 'Parent {number}',
// Still referenced by `Onboarding.tsx`'s balances step; the balance flow
// is moving to a `balance.*` namespace — drop this key once nothing else
// reads it.
'dashboard.fetchFromFk': 'Fetch from Försäkringskassan',
'dashboard.figureExpiring': 'Expiring at age 4',
'dashboard.figureLeft': 'Days left',
'dashboard.figureUsed': 'Used',
'dashboard.hero.hasPlanMessage': '{atRisk} days are at risk of disappearing when {childName} turns 4 ({date}).',
'dashboard.hero.noneAtRiskMessage': 'No days will be lost — your plan uses them all before {childName} turns 4 ({date}).',
'dashboard.hero.noPlanMessage': 'You have {days} days to plan. Without a plan, {atRisk} of them will disappear when {childName} turns 4.',
'dashboard.loading': 'Loading...',
'dashboard.moreDetailsSummary': 'More details',
'dashboard.leaveOverviewTitle': 'Until age 4',
'dashboard.moreDetailsSummary': 'Details',
'dashboard.overviewTitle': 'Overview',
'dashboard.planFold.noWishesHint': 'Add wishes under Calendar to see a proposal here.',
'dashboard.planFold.summary': 'Plan proposal for the years ahead',
'dashboard.planFold.unknownParent': 'Unknown parent',
'dashboard.plannedDaysTitle': 'Planned days',
'dashboard.saveManualFetchError': 'Could not save the details',
'dashboard.usedDays': 'Used days',
'daysBreakdown.daysHeader': 'Days',
'daysBreakdown.empty': 'No days registered yet',
'daysBreakdown.typeHeader': 'Leave type',
'overviewMetrics.defaultTitle': 'Overview',
'parentalLeaveBreakdown.dubbeldagarLeft': 'Double days left',
'parentalLeaveBreakdown.empty': 'No data fetched yet — press Fetch from Försäkringskassan.',
'parentalLeaveBreakdown.empty': 'No balances fetched yet.',
'parentalLeaveBreakdown.lagstaNiva': 'Minimum rate',
'parentalLeaveBreakdown.remainingHeader': 'Remaining',
'parentalLeaveBreakdown.reservedNote': '{reserved} of your days are reserved and cannot be transferred to the other parent (up to {transferable} days can be transferred).',
'parentalLeaveBreakdown.leftOfTotal': '{left} of {total}',
'parentalLeaveBreakdown.noBalance': 'No balance yet',
'parentalLeaveBreakdown.reservedNote': '{reserved} of each parent\'s days are reserved and cannot be transferred to the other parent (up to {transferable} days can be transferred).',
'parentalLeaveBreakdown.sjukpenning': 'Sjukpenningnivå (income-based)',
'parentalLeaveBreakdown.sourceManual': 'Manually entered',
'parentalLeaveBreakdown.sourceScraped': 'Fetched from Försäkringskassan',
'parentalLeaveBreakdown.totalHeader': 'Total',
'parentalLeaveBreakdown.transferableDays': 'Transferable days',
'parentalLeaveBreakdown.typeHeader': 'Type',
'yearTimeline.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} days). Click to open this month in the calendar.',
'yearTimeline.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 day). Click to open this month in the calendar.',
'yearTimeline.caption': 'Each row is one year; each colored band is a planned leave period. Click a band to open that month in the calendar.',
'yearTimeline.title': 'The years ahead',
'yearRow.assumed': 'assumed',
'yearRow.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} days). Click to open in the calendar.',
'yearRow.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 day). Click to open in the calendar.',
'yearRow.carriedIn': 'Carried in',
'yearRow.carriedOut': 'saved {days}',
'yearRow.carriedOutRow': 'Saved for next year',
'yearRow.earned': 'Earned',
'yearRow.expiring': 'expiring {days}',
'yearRow.expiringRow': 'Expiring',
'yearRow.fpLeft': 'PL left',
'yearRow.left': 'Left',
'yearRow.leftAtYearEnd': 'Left at year end',
'yearRow.parentalLeaveTitle': 'Parental leave',
'yearRow.plannedUse': 'Planned',
'yearRow.usedThisYear': 'Used this year',
'yearRow.vacationLeft': 'Vacation left',
'yearRow.vacationTitle': 'Vacation',
'yearRow.weeksFunded': '{funded} of {wished} weeks',
'yearRow.wishesTitle': 'Leave periods',
} as const
+4 -18
View File
@@ -1,16 +1,11 @@
// `plan.*` (the standalone Plan tab) and most `planResults.*` keys were
// retired with the Plan/Tips tabs (leave is now authored on the calendar and
// Översikt carries the headline figures — see PLANNING_ENGINE_SPEC.md's
// superseding note). What's left here is the subset `planResults.*`
// `PlanFold.tsx` (the read-only plan-proposal fold on Översikt) and
// `frontend/src/utils/rejectionMessages.ts` still render.
// superseding note). What's left here is the subset the dashboard's year
// rows (`YearRows.tsx`: shortfall + remedy lines), its surplus-advice tips
// (`SurplusAdviceTip.tsx`) and `frontend/src/utils/rejectionMessages.ts`
// still render.
export const plan = {
'planResults.balancesTitle': 'Remaining balances after the plan',
'planResults.dubbeldagarLeftHeader': 'Double days left',
'planResults.lagstaLeftHeader': 'Lägstanivå days left',
'planResults.leavesHeader': 'Leave periods',
'planResults.noWindowsInHorizon': 'No windows within the planning horizon.',
'planResults.parentHeader': 'Parent',
'planResults.remedyBodyCarryVacationForward': '{spare_total} vacation day(s) from earlier years can be carried forward',
'planResults.remedyBodyLowerPriorityWish': 'Lowering {wish_name} from {from_weeks} to {to_weeks} week(s) frees {days_freed} day(s)',
'planResults.remedyBodyReducePreschoolReserve': 'Reducing the preschool planning-day reserve ({reserved} reserved) frees {days_freed} day(s)',
@@ -26,22 +21,13 @@ export const plan = {
'planResults.rejectionInvalidDayType': 'Day {index} ({date}): invalid day type "{day_type}"',
'planResults.rejectionOutsideEligibleWindow': '{date}: outside the child\'s eligible window ({birth_date} – {hard_expiry})',
'planResults.shortfallLine': '{shortfall_days} day(s) could not be funded',
'planResults.shortfallsTitle': 'Periods with a shortfall',
'planResults.sjukpenningLeftHeader': 'Sjukpenningnivå days left',
'planResults.surplusAdviceBodyFrontLoadEarlyYears': '{parent_name} has a large surplus ({days_involved} parental-benefit days) — take more days now while the child is still small and you are home anyway, rather than spreading them thinly until the 4th birthday.',
'planResults.surplusAdviceBodyShiftMixToFp': '{parent_name} could swap {days_involved} vacation day(s) for parental-benefit days in this plan — the parental-benefit days would otherwise be forfeited at the 4th birthday, while the vacation days would not.',
'planResults.surplusAdviceBodyUseLessVacation': '{parent_name} could take {days_involved} fewer vacation day(s) ({years_text}) without going below the statutory minimum ({min_vacation_days_per_year} days/year) — save them instead to cover parental-benefit days that would otherwise be forfeited.',
'planResults.surplusAdviceBodyWishMoreLeave': '{parent_name} can afford {weeks} more week(s) of leave ({days_involved} parental-benefit days would otherwise be forfeited at the child\'s 4th birthday) — add a week in the summer or an autumn break.',
'planResults.surplusAdviceFrontLoadEarlyYears': 'Take more leave while the child is small',
'planResults.surplusAdviceIntro': 'This plan leaves days unused that would otherwise be lost at the child\'s 4th birthday. A few suggestions:',
'planResults.surplusAdviceShiftMixToFp': 'Spend parental-benefit days instead of vacation',
'planResults.surplusAdviceTitle': 'You have unused days',
'planResults.surplusAdviceUseLessVacation': 'Save fewer vacation days',
'planResults.surplusAdviceWishMoreLeave': 'Wish for more leave',
'planResults.totalAvailableHeader': 'Total available',
'planResults.unknownParent': 'Unknown parent',
'planResults.usedHeader': 'Used',
'planResults.weeksUnit': 'weeks',
'planResults.yearHeader': 'Year',
'planResults.yearOverviewTitle': 'Overview by year',
} as const
+36 -25
View File
@@ -2,44 +2,55 @@ export const dashboard = {
'age4Bars.expiringStat': '{expiring} dagar förfaller',
'age4Bars.savableStat': '{left} av {cap} dagar kan sparas',
'age4Bars.title': 'Dagar kvar vid 4 års ålder, per förälder',
'dashboard.balancePickerPrompt': 'Vem gäller det?',
'dashboard.balanceLink': 'Uppdatera saldon',
'dashboard.daysLeft': 'Kvarvarande dagar',
'dashboard.enterManually': 'Ange uppgifter manuellt',
'dashboard.estimatedBadge': 'Uppskattat',
'dashboard.estimatedBadgeCaption': 'Inga verkliga saldon är hämtade än — siffrorna bygger på det lagstadgade antagandet (480 dagar, inget uttaget). Hämta från Försäkringskassan eller ange uppgifter manuellt för exakta siffror.',
'dashboard.estimatedBadgeCaption': 'Inga verkliga saldon är hämtade än — siffrorna utgår från 480 dagar, inget uttaget. Uppdatera saldon under Inställningar.',
'dashboard.expiringAtB4': 'Förfaller vid 4 år om nuvarande plan följs (alla föräldrar)',
'dashboard.expiringAtB4ForParent': 'Förfaller vid 4 år om nuvarande plan följs – {name}',
'dashboard.expiringAtB4ParentFallback': 'Förälder {number}',
// Still referenced by `Onboarding.tsx`'s balances step; the balance flow
// is moving to a `balance.*` namespace — drop this key once nothing else
// reads it.
'dashboard.fetchFromFk': 'Hämta från Försäkringskassan',
'dashboard.figureExpiring': 'Förfaller vid 4 år',
'dashboard.figureLeft': 'Dagar kvar',
'dashboard.figureUsed': 'Använda',
'dashboard.hero.hasPlanMessage': '{atRisk} dagar riskerar att försvinna när {childName} fyller 4 ({date}).',
'dashboard.hero.noneAtRiskMessage': 'Inga dagar går förlorade — er plan hinner använda alla innan {childName} fyller 4 ({date}).',
'dashboard.hero.noPlanMessage': 'Du har {days} dagar att planera. Utan en plan försvinner {atRisk} av dem när {childName} fyller 4.',
'dashboard.loading': 'Laddar...',
'dashboard.moreDetailsSummary': 'Fler detaljer',
'dashboard.leaveOverviewTitle': 'Fram till 4 år',
'dashboard.moreDetailsSummary': 'Detaljer',
'dashboard.overviewTitle': 'Översikt',
'dashboard.planFold.noWishesHint': 'Lägg till önskemål under Kalender för att se ett förslag här.',
'dashboard.planFold.summary': 'Planförslag för de kommande åren',
'dashboard.planFold.unknownParent': 'Okänd förälder',
'dashboard.plannedDaysTitle': 'Planerade dagar',
'dashboard.saveManualFetchError': 'Kunde inte spara uppgifterna',
'dashboard.usedDays': 'Använda dagar',
'daysBreakdown.daysHeader': 'Dagar',
'daysBreakdown.empty': 'Inga dagar registrerade än',
'daysBreakdown.typeHeader': 'Typ av ledighet',
'overviewMetrics.defaultTitle': 'Översikt',
'parentalLeaveBreakdown.dubbeldagarLeft': 'Dubbeldagar kvar',
'parentalLeaveBreakdown.empty': 'Ingen data hämtad ännu — tryck på Hämta från Försäkringskassan.',
'parentalLeaveBreakdown.lagstaNiva': 'Lägsta nivå',
'parentalLeaveBreakdown.remainingHeader': 'Återstående',
'parentalLeaveBreakdown.reservedNote': '{reserved} av dina dagar är reserverade och kan inte överlåtas till den andra föräldern (max {transferable} dagar kan överlåtas).',
'parentalLeaveBreakdown.sjukpenning': 'Sjukpenning',
'parentalLeaveBreakdown.empty': 'Inga saldon hämtade än.',
'parentalLeaveBreakdown.lagstaNiva': 'Lägstanivå',
'parentalLeaveBreakdown.leftOfTotal': '{left} av {total}',
'parentalLeaveBreakdown.noBalance': 'Inget saldo än',
'parentalLeaveBreakdown.reservedNote': '{reserved} av varje förälders dagar är reserverade och kan inte överlåtas till den andra föräldern (max {transferable} dagar kan överlåtas).',
'parentalLeaveBreakdown.sjukpenning': 'Sjukpenningnivå',
'parentalLeaveBreakdown.sourceManual': 'Manuellt angiven',
'parentalLeaveBreakdown.sourceScraped': 'Hämtad från Försäkringskassan',
'parentalLeaveBreakdown.totalHeader': 'Totalt',
'parentalLeaveBreakdown.transferableDays': 'Överlåtbara dagar',
'parentalLeaveBreakdown.typeHeader': 'Typ',
'yearTimeline.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} dagar). Klicka för att öppna denna månad i kalendern.',
'yearTimeline.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 dag). Klicka för att öppna denna månad i kalendern.',
'yearTimeline.caption': 'Varje rad är ett år och varje färgad stapel en planerad ledighetsperiod. Klicka på en stapel för att öppna den månaden i kalendern.',
'yearTimeline.title': 'De kommande åren',
} as const
'yearRow.assumed': 'antaget',
'yearRow.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} dagar). Klicka för att öppna i kalendern.',
'yearRow.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 dag). Klicka för att öppna i kalendern.',
'yearRow.carriedIn': 'Sparade in',
'yearRow.carriedOut': 'sparas {days}',
'yearRow.carriedOutRow': 'Sparas till nästa år',
'yearRow.earned': 'Intjänade',
'yearRow.expiring': 'förfaller {days}',
'yearRow.expiringRow': 'Förfaller',
'yearRow.fpLeft': 'FP kvar',
'yearRow.left': 'Kvar',
'yearRow.leftAtYearEnd': 'Kvar vid årets slut',
'yearRow.parentalLeaveTitle': 'Föräldrapenning',
'yearRow.plannedUse': 'Planerade',
'yearRow.usedThisYear': 'Använda i år',
'yearRow.vacationLeft': 'Semester kvar',
'yearRow.vacationTitle': 'Semester',
'yearRow.weeksFunded': '{funded} av {wished} veckor',
'yearRow.wishesTitle': 'Ledigheter',
} as const
+4 -18
View File
@@ -1,16 +1,11 @@
// `plan.*` (the standalone Plan tab) and most `planResults.*` keys were
// retired with the Plan/Tips tabs (leave is now authored on the calendar and
// Översikt carries the headline figures — see PLANNING_ENGINE_SPEC.md's
// superseding note). What's left here is the subset `planResults.*`
// `PlanFold.tsx` (the read-only plan-proposal fold on Översikt) and
// `frontend/src/utils/rejectionMessages.ts` still render.
// superseding note). What's left here is the subset the dashboard's year
// rows (`YearRows.tsx`: shortfall + remedy lines), its surplus-advice tips
// (`SurplusAdviceTip.tsx`) and `frontend/src/utils/rejectionMessages.ts`
// still render.
export const plan = {
'planResults.balancesTitle': 'Kvarvarande saldon efter planen',
'planResults.dubbeldagarLeftHeader': 'Dubbeldagar kvar',
'planResults.lagstaLeftHeader': 'Lägstanivådagar kvar',
'planResults.leavesHeader': 'Ledigheter',
'planResults.noWindowsInHorizon': 'Inga fönster inom planeringshorisonten.',
'planResults.parentHeader': 'Förälder',
'planResults.remedyBodyCarryVacationForward': '{spare_total} semesterdagar från tidigare år kan sparas framåt',
'planResults.remedyBodyLowerPriorityWish': 'Sänk {wish_name} från {from_weeks} till {to_weeks} veckor frigör {days_freed} dagar',
'planResults.remedyBodyReducePreschoolReserve': 'Minska förskolans planeringsdagar ({reserved} reserverade) frigör {days_freed} dagar',
@@ -26,22 +21,13 @@ export const plan = {
'planResults.rejectionInvalidDayType': 'Dag {index} ({date}): ogiltig dagtyp "{day_type}"',
'planResults.rejectionOutsideEligibleWindow': '{date}: utanför barnets tillåtna period ({birth_date} – {hard_expiry})',
'planResults.shortfallLine': '{shortfall_days} dag(ar) kunde inte finansieras',
'planResults.shortfallsTitle': 'Perioder med underskott',
'planResults.sjukpenningLeftHeader': 'Sjukpenningdagar kvar',
'planResults.surplusAdviceBodyFrontLoadEarlyYears': '{parent_name} har ett stort överskott ({days_involved} FP-dagar) — ta ut fler dagar nu medan barnet är litet och ni ändå är hemma, istället för att sprida ut dem tunt fram till 4-årsdagen.',
'planResults.surplusAdviceBodyShiftMixToFp': '{parent_name} kan byta {days_involved} semesterdagar mot föräldrapenningdagar under planen — FP-dagarna går annars förlorade vid 4-årsdagen, medan semestern inte gör det.',
'planResults.surplusAdviceBodyUseLessVacation': '{parent_name} kan ta ut {days_involved} färre semesterdagar ({years_text}) utan att gå under lägsta uttaget ({min_vacation_days_per_year} dagar/år) — spara dem istället för att täcka FP-dagar som annars går förlorade.',
'planResults.surplusAdviceBodyWishMoreLeave': '{parent_name} har råd med ytterligare {weeks} veckor ledigt ({days_involved} FP-dagar går annars förlorade vid barnets 4-årsdag) — lägg till en vecka på sommaren eller ett höstlov.',
'planResults.surplusAdviceFrontLoadEarlyYears': 'Ta ut mer ledighet medan barnet är litet',
'planResults.surplusAdviceIntro': 'Planen lämnar dagar outnyttjade som annars går förlorade vid barnets 4-årsdag. Några förslag:',
'planResults.surplusAdviceShiftMixToFp': 'Använd föräldradagar istället för semester',
'planResults.surplusAdviceTitle': 'Ni har outnyttjade dagar',
'planResults.surplusAdviceUseLessVacation': 'Spara färre semesterdagar',
'planResults.surplusAdviceWishMoreLeave': 'Önska mer ledighet',
'planResults.totalAvailableHeader': 'Totalt tillgängliga',
'planResults.unknownParent': 'Okänd förälder',
'planResults.usedHeader': 'Använda',
'planResults.weeksUnit': 'veckor',
'planResults.yearHeader': 'År',
'planResults.yearOverviewTitle': 'Översikt över åren',
} as const
File diff suppressed because it is too large Load Diff
+112 -227
View File
@@ -1,37 +1,30 @@
import { ReactNode, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ReactNode, useEffect, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useWorkspace } from '@/context'
import {
useAnalytics,
useLeaveWishes,
usePlanProposal,
usePlannedDays,
useScraperFetch,
useParentalLeave,
useWorkspaceMembers,
useOptimizationTips,
} from '@/hooks'
// Imported directly (not via the `@/hooks` barrel) so this page's tests can
// drive it through a mocked `api.getParentalLeave` — see the hook's doc
// comment for why.
import { useLatestBalance } from '@/hooks/useLatestBalance'
import type { PlannedLeaveDay } from '@/types'
import * as api from '@/services/api'
import { getLabelForType } from '@/utils/leaveTypes'
// Imported directly (not via the `@/hooks` barrel) so this page's test can
// mock this one module while the barrel stays automocked.
import { useParentalLeaves } from '@/hooks/useParentalLeaves'
import { nameResolver } from '@/utils/parentNames'
import { isSelf, orderSelfFirst } from '@/utils/members'
import { orderSelfFirst } from '@/utils/members'
import { useTranslation } from '@/i18n'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { PageSkeleton } from '@/components/common/PageSkeleton'
import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics'
import { DaysBreakdown } from '@/components/dashboard/DaysBreakdown'
import { Hero } from '@/components/dashboard/Hero'
import { KeyFigures } from '@/components/dashboard/KeyFigures'
import { Age4Bars } from '@/components/dashboard/Age4Bars'
import { YearTimeline } from '@/components/dashboard/YearTimeline'
import { PlanFold } from '@/components/dashboard/PlanFold'
import { YearRows } from '@/components/dashboard/YearRows'
import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip'
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { TipCard } from '@/components/inputs/TipCard'
import { QRCodeModal } from '@/components/modals/QRCodeModal'
import { ParentBalanceForm } from '@/components/modals/ParentBalanceForm'
import { Onboarding } from '@/pages/Onboarding'
/** Fallback horizon end when the analytics projection isn't available yet
@@ -47,30 +40,21 @@ export function Dashboard(): ReactNode {
const { t } = useTranslation()
const navigate = useNavigate()
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid } = useWorkspace()
const { data: analytics, isLoading: analyticsLoading, error: analyticsError, refresh: refreshAnalytics } = useAnalytics(
const { data: analytics, isLoading: analyticsLoading, error: analyticsError } = useAnalytics(
workspace?.id ?? null,
selectedChildId ?? ''
)
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined)
const { data: parentalLeave, refresh: refreshParentalLeave } = useParentalLeave(personalDataUuid, selectedChildId ?? null)
const { data: members, isLoading: membersLoading, refresh: refreshMembers } = useWorkspaceMembers(workspace?.id ?? null)
const { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null)
const scraper = useScraperFetch()
const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspace?.id ?? null)
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspace?.id ?? null)
const [showModal, setShowModal] = useState(false)
const [showBalancePicker, setShowBalancePicker] = useState(false)
const [showManualFetchModal, setShowManualFetchModal] = useState(false)
const [manualFetchTargetUuid, setManualFetchTargetUuid] = useState<string | null>(null)
const [isManualFetchSaving, setIsManualFetchSaving] = useState(false)
// Reopening the balance form for a parent should show what was actually
// saved for them, not always the 195/45/60 preset — `manualFetchTargetUuid`
// is null except while the form is open/opening, so this no-ops the rest
// of the time.
const {
initial: manualFetchInitial,
isLoading: manualFetchInitialLoading,
} = useLatestBalance(manualFetchTargetUuid, selectedChildId ?? null)
const orderedMembers = orderSelfFirst(members, personalDataUuid)
const { data: leaves, isLoading: leavesLoading } = useParentalLeaves(
orderedMembers.map((m) => m.uuid),
selectedChildId ?? null,
)
// `useWorkspaceMembers` fetches once per `workspace.id`, which never
// changes across onboarding — so a couple's partner, added mid-wizard via
@@ -89,6 +73,16 @@ export function Dashboard(): ReactNode {
wasAwaitingFirstChildRef.current = children.length === 0
}, [children.length, refreshMembers])
// The plan proposal (`POST /api/plan/propose`, read-only — persists
// nothing) is the only source for wished-vs-funded per year and the
// surplus advice, so it is requested automatically once wishes exist.
// Stops after one failure rather than retrying on every render.
useEffect(() => {
if (!wishesLoading && wishes.length > 0 && proposal === null && !isProposing && proposalError === null) {
void propose()
}
}, [wishesLoading, wishes.length, proposal, isProposing, proposalError, propose])
const isLoading = wsLoading || analyticsLoading || pdLoading
if (isLoading) {
@@ -99,96 +93,15 @@ export function Dashboard(): ReactNode {
)
}
const handleFetchFK = async () => {
setShowModal(true)
await scraper.start(selectedChildId ?? undefined)
}
const handleSaveManualFetch = async (data: {
sjukpenningLeft: number
lagstaNivaLeft: number
dubbeldagarLeft: number
reservedDaysLeft: number | null
}) => {
// `manualFetchTargetUuid` is always set to a real uuid by
// `handleOpenManualFetch`/`handlePickManualFetchTarget` before the form
// is ever shown — there is no path that calls this with it unset.
const targetUuid = manualFetchTargetUuid
if (!workspace?.id || !selectedChildId || !targetUuid) return
// No try/catch here on purpose — a rejection propagates back to
// `ParentBalanceForm`'s own `onSave` handler, which owns showing it
// (inline under the offending field for a mapped 422, or its own top
// banner otherwise). See the audit note on `ParentBalanceForm.tsx`.
try {
setIsManualFetchSaving(true)
await api.saveManualFetch({
personalDataUuid: targetUuid,
workspaceId: workspace.id,
childId: selectedChildId,
sjukpenningLeft: data.sjukpenningLeft,
lagstaNivaLeft: data.lagstaNivaLeft,
dubbeldagarLeft: data.dubbeldagarLeft,
reservedDaysLeft: data.reservedDaysLeft,
source: 'manual',
})
await refreshParentalLeave()
await refreshAnalytics()
setShowManualFetchModal(false)
// Reset so the next open of this same target refetches instead of
// reusing the (now stale) balance `useLatestBalance` already cached.
setManualFetchTargetUuid(null)
} finally {
setIsManualFetchSaving(false)
}
}
/** "Ange manuellt" — for a solo workspace this opens the form straight for
* self; for a couple it shows a small parent picker first (see
* `dashboard-balance-parent-picker` below) since either parent's balance
* can be entered from here. */
const handleOpenManualFetch = () => {
if (members.length >= 2) {
setShowBalancePicker(true)
return
}
setManualFetchTargetUuid(personalDataUuid)
setShowManualFetchModal(true)
}
const handlePickManualFetchTarget = (uuid: string) => {
setManualFetchTargetUuid(uuid)
setShowBalancePicker(false)
setShowManualFetchModal(true)
}
const handleModalClose = () => {
if (scraper.isRunning) return
setShowModal(false)
}
const handleFetchDone = async () => {
setShowModal(false)
await Promise.all([refreshAnalytics(), refreshParentalLeave()])
}
const handleBandClick = (date: string) => {
navigate(`/calendar?date=${date}`)
}
const breakdown = [
{ type: getLabelForType('vacation', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'vacation').length ?? 0 },
{ type: getLabelForType('sjukpenning', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'sjukpenning').length ?? 0 },
{ type: getLabelForType('lagsta', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'lagsta').length ?? 0 },
]
// Per-parent expiring-day figures are only informative once there's more
// than one parent to compare — with a single parent they'd just repeat
// the combined total below, so skip the breakdown in that case.
const nameFor = nameResolver(members)
const parentProjections = analytics?.parentProjections ?? []
// Per-parent expiring-day figures are only informative once there's more
// than one parent to compare — with a single parent they'd just repeat
// the combined total, so skip the breakdown in that case.
const showPerParentExpiring = parentProjections.length > 1
const parentDisplayName = (uuid: string, index: number): string => {
@@ -197,6 +110,10 @@ export function Dashboard(): ReactNode {
? t('dashboard.expiringAtB4ParentFallback', { number: index + 1 })
: resolved
}
const nameForAdvice = (uuid: string): string => {
const resolved = nameFor(uuid)
return resolved === uuid ? t('planResults.unknownParent') : resolved
}
const overviewMetrics = analytics
? [
@@ -216,9 +133,7 @@ export function Dashboard(): ReactNode {
value: analytics.combinedExpiringAtB4,
testId: 'metric-expiring-combined',
// Only actually a distinct "all parents" summary when there was
// a per-parent breakdown above it to summarize — for a single
// parent it's just this workspace's one figure, so it renders as
// an ordinary grid cell instead of a footer row.
// a per-parent breakdown above it to summarize.
isSummary: showPerParentExpiring,
},
]
@@ -237,13 +152,43 @@ export function Dashboard(): ReactNode {
const hasPlan = (plannedDays?.length ?? 0) > 0
const fourthBirthday =
parentProjections[0]?.fourthBirthday ?? (selectedChild ? computeFourthBirthday(selectedChild.birth_date) : '')
const age4Bars = parentProjections.map((projection, index) => ({
// Self first, matching the order of every per-parent list on the page.
const orderedProjections = [
...parentProjections.filter((p) => p.personalDataUuid === personalDataUuid),
...parentProjections.filter((p) => p.personalDataUuid !== personalDataUuid),
]
const projectionName = (uuid: string) =>
parentDisplayName(uuid, parentProjections.findIndex((p) => p.personalDataUuid === uuid))
const age4Bars = orderedProjections.map((projection) => ({
personalDataUuid: projection.personalDataUuid,
name: parentDisplayName(projection.personalDataUuid, index),
name: projectionName(projection.personalDataUuid),
projectedLeftAtB4: projection.projectedLeftAtB4,
savableCapAtB4: projection.savableCapAtB4,
expiringAtB4: projection.expiringAtB4,
}))
const yearRowParents = orderedProjections.map((projection) => ({
personalDataUuid: projection.personalDataUuid,
name: projectionName(projection.personalDataUuid),
yearBalances: projection.yearBalances,
}))
// "Estimated" once the members are known and at least one of them has no
// saved balance — the headline figures then rest on the statutory
// assumption for that parent. Absent from the map = not fetched yet, so
// the line never flashes before the first response.
const showEstimate =
!leavesLoading && orderedMembers.length > 0 && orderedMembers.some((m) => leaves[m.uuid] === null)
const breakdownParents = orderedMembers.map((member) => ({
personalDataUuid: member.uuid,
name: member.name,
data: leaves[member.uuid] ?? null,
}))
const surplusAdvice = proposal?.surplusAdvice ?? []
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0
return (
<Layout>
@@ -264,127 +209,67 @@ export function Dashboard(): ReactNode {
/>
)}
{!parentalLeave && (
<p
className="badge badge--warning dashboard__estimate-badge"
data-testid="dashboard-estimate-badge"
title={t('dashboard.estimatedBadgeCaption')}
>
{t('dashboard.estimatedBadge')}
{showEstimate && (
<p className="dashboard__estimate">
<span className="dashboard__estimate-badge" data-testid="dashboard-estimate-badge">
{t('dashboard.estimatedBadge')}
</span>{' '}
{t('dashboard.estimatedBadgeCaption')}
</p>
)}
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
<section className="dashboard__section dashboard__fetch-actions">
<button type="button" className="btn btn--primary" onClick={handleFetchFK} disabled={!selectedChildId}>
{t('dashboard.fetchFromFk')}
</button>
<button
type="button"
onClick={handleOpenManualFetch}
disabled={!selectedChildId}
className="btn btn--secondary"
>
{t('dashboard.enterManually')}
</button>
</section>
{showBalancePicker && (
<div className="card dashboard__balance-picker" data-testid="dashboard-balance-parent-picker">
<p className="dashboard__balance-picker-label">{t('dashboard.balancePickerPrompt')}</p>
<div className="dashboard__balance-picker-actions">
{orderSelfFirst(members, personalDataUuid).map((member) => (
<button
key={member.uuid}
type="button"
className="btn btn--secondary"
onClick={() => handlePickManualFetchTarget(member.uuid)}
data-testid={`dashboard-balance-parent-${member.uuid}`}
>
{member.name}
{isSelf(member, personalDataUuid) && ` ${t('settings.youBadge')}`}
</button>
))}
</div>
</div>
{analytics && (
<KeyFigures
daysLeft={analytics.combinedDaysLeft}
daysUsed={analytics.combinedDaysUsed}
expiringAtB4={analytics.combinedExpiringAtB4}
/>
)}
{selectedChild && (
<YearTimeline
plannedDays={plannedDays ?? []}
startDate={selectedChild.birth_date}
endDate={fourthBirthday}
onBandClick={handleBandClick}
/>
<section className="dashboard__section year-overview" data-testid="year-overview">
<h2>{t('dashboard.leaveOverviewTitle')}</h2>
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
{proposalError && (
<div className="alert alert--error" role="alert" data-testid="dashboard-proposal-error">
{proposalError}
</div>
)}
<YearRows
parents={yearRowParents}
plannedDays={plannedDays ?? []}
startDate={selectedChild.birth_date}
endDate={fourthBirthday}
outcomes={proposal?.outcomes ?? []}
onBandClick={handleBandClick}
/>
</section>
)}
{tips && tips.length > 0 && (
{hasTips && (
<section className="dashboard__section" data-testid="dashboard-tips">
<h2>{t('tips.title')}</h2>
<div className="dashboard__tips-list">
{tips.map((tip) => (
{(tips ?? []).map((tip) => (
<TipCard key={tip.id} tip={tip} onDismiss={() => dismissTip(tip.id)} />
))}
{surplusAdvice.map((advice, index) => (
<SurplusAdviceTip key={`${advice.kind}-${index}`} advice={advice} nameFor={nameForAdvice} index={index} />
))}
</div>
</section>
)}
{workspace && <PlanFold workspaceId={workspace.id} members={members} />}
<details className="dashboard__more-details" data-testid="dashboard-more-details">
<summary>{t('dashboard.moreDetailsSummary')}</summary>
<section className="dashboard__section">
{analytics && (
<OverviewMetrics
title={t('dashboard.overviewTitle')}
metrics={overviewMetrics}
/>
)}
<ParentalLeaveBreakdown data={parentalLeave} />
</section>
<section className="dashboard__section">
<h2>{t('dashboard.plannedDaysTitle')}</h2>
<DaysBreakdown breakdown={breakdown} />
</section>
</details>
<QRCodeModal
isOpen={showModal}
onClose={handleModalClose}
onDone={handleFetchDone}
status={scraper.pollData?.status ?? null}
qrData={scraper.pollData?.qr_data}
result={scraper.pollData?.result}
message={scraper.pollData?.message}
error={scraper.error}
isRunning={scraper.isRunning}
onCancel={scraper.cancel}
/>
{showManualFetchModal && manualFetchInitialLoading && (
<div className="modal__overlay" data-testid="dashboard-balance-loading">
<LoadingSpinner message={t('parentBalance.loadingBalance')} />
<details className="disclosure dashboard__details" data-testid="dashboard-more-details">
<summary className="disclosure__summary">{t('dashboard.moreDetailsSummary')}</summary>
<div className="disclosure__body dashboard__details-body">
{analytics && <OverviewMetrics title={t('dashboard.overviewTitle')} metrics={overviewMetrics} />}
<ParentalLeaveBreakdown parents={breakdownParents} />
<Link to="/settings#saldon" className="btn btn--link" data-testid="dashboard-balance-link">
{t('dashboard.balanceLink')}
</Link>
</div>
)}
{showManualFetchModal && !manualFetchInitialLoading && (
<ParentBalanceForm
parentName={members.find((m) => m.uuid === manualFetchTargetUuid)?.name ?? t('calendar.you')}
isSelf={manualFetchTargetUuid === personalDataUuid}
totalDays={members.length >= 2 ? 240 : 480}
initial={manualFetchInitial}
onSave={handleSaveManualFetch}
onClose={() => {
setShowManualFetchModal(false)
setManualFetchTargetUuid(null)
}}
isSaving={isManualFetchSaving}
error={null}
/>
)}
</details>
</div>
</Layout>
)
File diff suppressed because it is too large Load Diff
+3
View File
@@ -77,6 +77,9 @@
/* Leave types: fill / ink / outline */
--c-leave-vacation: #eef2fe;
--c-leave-vacation-ink: #2f5bea;
/* Stronger vacation step for thin (8px) timeline tracks only — the tint
* above vanishes on white at that height. Calendar bands keep the tint. */
--c-leave-vacation-bar: #b9c9f7;
--c-leave-sjukpenning: #2a2d35;
--c-leave-sjukpenning-ink: #fff;
--c-leave-lagsta: #fff;
+40 -23
View File
@@ -74,12 +74,6 @@
margin-bottom: var(--sp-4);
}
/* Metrics - single column */
.overview-metrics__list,
[data-testid='metrics-list'] {
grid-template-columns: 1fr;
}
/* Forms - full width */
.form-group input,
.form-group select,
@@ -100,21 +94,55 @@
}
/* Tables */
.days-breakdown__table,
.parental-leave-breakdown__table,
.vacation-days-table {
font-size: var(--fs-sm);
}
.days-breakdown__table th,
.days-breakdown__table td,
.parental-leave-breakdown__table th,
.parental-leave-breakdown__table td,
.vacation-days-table th,
.vacation-days-table td {
padding: var(--sp-2) var(--sp-3);
}
/* Dashboard — the three key figures tighten; a year row keeps year +
* track on the first line and wraps the per-parent figures under the
* track, each parent on its own line. */
.key-figures {
gap: var(--sp-4) var(--sp-6);
}
.year-row__summary {
grid-template-columns: 3rem minmax(0, 1fr) var(--icon-sm);
}
.year-row__summary::after {
grid-column: 3;
}
.year-row__figures {
grid-row: 2;
grid-column: 2 / -1;
display: flex;
flex-direction: column;
gap: var(--sp-1);
white-space: normal;
}
.year-row__parent {
display: flex;
flex-wrap: wrap;
column-gap: var(--sp-3);
row-gap: 0;
}
.year-row__body {
padding-left: 0;
}
.year-row__table,
.parental-leave-breakdown__table {
width: 100%;
}
.vacation-days-table__input {
width: 70px;
}
@@ -154,13 +182,6 @@
order: -10;
}
/* Lists */
.dashboard__break-item {
flex-direction: column;
align-items: flex-start;
gap: var(--sp-2);
}
/* Loading spinner */
.loading-spinner {
padding: var(--sp-5);
@@ -207,10 +228,6 @@
/* Tablet - Medium screens (641px to 1024px) */
@media (min-width: 641px) and (max-width: 1024px) {
.overview-metrics__list {
grid-template-columns: repeat(2, 1fr);
}
.form-grid {
grid-template-columns: repeat(2, 1fr);
}