Files
F-r-ldraledigplannerare/frontend/src/pages/Dashboard.test.tsx
T
jacobh2 d14f86478a Lay the Almanacka design foundation: tokens, type, shell
New token system in global.css (near-white surfaces, one ink-blue
accent, no sub-12px type), self-hosted Schibsted Grotesk, rewritten
primitives (buttons, fields, modal shell, segmented, disclosure,
skeleton), a borderless 56px header with an SV | EN language toggle,
an Icon component replacing glyphs, page skeletons that keep the
layout during loading, and leave-type colours moved out of TS into
CSS variables. Feature blocks keep their structure for later phases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 18:25:04 +02:00

766 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BrowserRouter } from 'react-router-dom'
import { Dashboard } from './Dashboard'
import * as hooks from '@/hooks'
import * as api from '@/services/api'
import { useWorkspace, useAuth } from '@/context'
import {
makeAuthContextValue,
makeWorkspaceContextValue,
makeAnalyticsResult,
makeParentalLeaveResult,
makePlannedDaysResult,
makeScraperFetchResult,
makeWorkspaceMembersResult,
makePlannedLeaveDay,
makeOptimizationTipsResult,
makeLeaveWishesResult,
makePlanProposalResult,
makeParentalLeaveFetch,
makeTierData,
makeTip,
} from '../../tests/fixtures'
const mockNavigate = vi.fn()
vi.mock('@/hooks')
vi.mock('@/context')
vi.mock('@/services/api')
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return { ...actual, useNavigate: () => mockNavigate }
})
// Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router tree
// to work. Dashboard.tsx only cares that its content renders inside Layout, so
// stub Layout out — same pattern used by Calendar.test.tsx / Settings.test.tsx.
vi.mock('@/components/layout/Layout', () => ({
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
// Dashboard hands off to Onboarding entirely when there are no children yet —
// stub it out so this file only asserts the hand-off happens, not
// Onboarding's own behaviour (covered by Onboarding.test.tsx).
vi.mock('@/pages/Onboarding', () => ({
Onboarding: () => <div data-testid="onboarding-page">Onboarding</div>,
}))
const renderWithRouter = (component: React.ReactElement) => {
return render(<BrowserRouter>{component}</BrowserRouter>)
}
describe('Dashboard', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAuth).mockReturnValue(makeAuthContextValue())
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
workspaceId: 'ws-1',
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }],
selectedChildId: 'c-1',
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
})
)
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [],
combinedDaysUsed: 30,
combinedDaysLeft: 270,
combinedExpiringAtB4: 100,
},
})
)
vi.mocked(hooks.useParentalLeave).mockReturnValue(
makeParentalLeaveResult({
data: {
id: 'pf-1',
personalDataUuid: 'pd-1',
workspaceId: 'ws-1',
childId: 'c-1',
source: 'scraped',
fetchedAt: '2026-06-13T10:00:00Z',
totalDays: 300,
daysUsed: 30,
daysLeft: 270,
reservedDaysTotal: 90,
reservedDaysLeft: 80,
transferableDaysLeft: 50,
dubbeldagarLeft: 60,
tierData: {
sjukpenningTotal: 100,
sjukpenningUsed: 10,
sjukpenningLeft: 90,
lagstaNivaTotal: 200,
lagstaNivaUsed: 20,
lagstaNivaLeft: 180,
},
createdAt: '2026-06-13T10:00:00Z',
updatedAt: '2026-06-13T10:00:00Z',
},
})
)
vi.mocked(hooks.usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({ id: '1', date: '2026-06-15' }),
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'sjukpenning' }),
],
})
)
vi.mocked(hooks.useScraperFetch).mockReturnValue(makeScraperFetchResult())
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [
{ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' },
{ uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' },
],
})
)
vi.mocked(hooks.useOptimizationTips).mockReturnValue(makeOptimizationTipsResult())
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult())
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult())
// Default: nothing saved yet for whoever's balance form gets opened —
// `useLatestBalance` (a real hook, imported directly by Dashboard.tsx to
// bypass the `vi.mock('@/hooks')` above) falls back to the form's
// preset. Tests that care about a saved balance override this.
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
})
it('renders loading spinner initially', () => {
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
workspaceId: 'ws-1',
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }],
selectedChildId: 'c-1',
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
isLoading: true,
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
})
it('hands off to Onboarding when there are no children yet', () => {
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
workspaceId: 'ws-1',
children: [],
selectedChildId: null,
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('onboarding-page')).toBeInTheDocument()
expect(screen.queryByTestId('dashboard')).not.toBeInTheDocument()
})
it('refetches workspace members once onboarding hands off (children goes from empty to non-empty)', async () => {
// Regression: a couple's partner is added mid-onboarding via a direct
// `api.addPartner` call, invisible to this component's own
// `useWorkspaceMembers` instance (fetched once, on mount, back when
// there was only one member). `Onboarding` only ever refreshes
// `WorkspaceContext` — flipping `children` from empty to non-empty is
// the only externally-observable signal this component gets that the
// member list it already fetched might now be stale.
const refreshMembers = vi.fn().mockResolvedValue(undefined)
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({ refresh: refreshMembers, data: [] }),
)
const soloWorkspace = makeWorkspaceContextValue({
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
workspaceId: 'ws-1',
children: [],
selectedChildId: null,
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
})
vi.mocked(useWorkspace).mockReturnValue(soloWorkspace)
const { rerender } = renderWithRouter(<Dashboard />)
expect(screen.getByTestId('onboarding-page')).toBeInTheDocument()
expect(refreshMembers).not.toHaveBeenCalled()
// Onboarding finishes: `WorkspaceContext.children` flips non-empty.
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
...soloWorkspace,
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2026-01-01' }],
selectedChildId: 'c-1',
})
)
rerender(
<BrowserRouter>
<Dashboard />
</BrowserRouter>,
)
await waitFor(() => expect(refreshMembers).toHaveBeenCalledTimes(1))
})
describe('estimated-figures badge', () => {
it('is hidden once real parental-leave balances have been fetched (the default mock)', () => {
renderWithRouter(<Dashboard />)
expect(screen.queryByTestId('dashboard-estimate-badge')).not.toBeInTheDocument()
})
it('is shown when no parental-leave balance has ever been fetched, labelling the figures as estimates', () => {
vi.mocked(hooks.useParentalLeave).mockReturnValue(makeParentalLeaveResult({ data: null }))
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-estimate-badge')).toHaveTextContent('Uppskattat')
})
})
describe('Hero', () => {
it('uses the forward-looking framing when there are no planned days yet', () => {
vi.mocked(hooks.usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: [] }))
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
'Du har 270 dagar att planera. Utan en plan försvinner 100 av dem när Child 1 fyller 4.',
)
})
it('uses the warning framing once days are planned, with the 4th-birthday date', () => {
// selectedChild birth_date is 2022-01-01 and there is no parent
// projection in this test's default mock, so the 4th birthday is
// derived client-side as plain date arithmetic (not a business rule).
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
'100 dagar riskerar att försvinna när Child 1 fyller 4 (1 januari 2026).',
)
})
it('uses the backend-provided 4th birthday when a parent projection is available', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2029-06-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
],
combinedDaysUsed: 30,
combinedDaysLeft: 210,
combinedExpiringAtB4: 60,
},
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('1 juni 2029')
})
})
describe('Age4Bars', () => {
it('renders each parent bar using the backend-resolved savableCapAtB4, never re-derived', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 1, plannedDaysBeforeB4: 0, projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48 },
],
combinedDaysUsed: 31,
combinedDaysLeft: 279,
combinedExpiringAtB4: 191,
},
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('age4-bar-savable-pd-1')).toHaveTextContent('40 av 48 dagar kan sparas')
expect(screen.getByTestId('age4-bar-pd-1')).toHaveTextContent('Alex')
expect(screen.getByTestId('age4-bar-expiring-pd-2')).toHaveTextContent('191 dagar förfaller')
expect(screen.getByTestId('age4-bar-pd-2')).toHaveTextContent('Sam')
})
it('is not rendered when there are no parent projections yet', () => {
renderWithRouter(<Dashboard />)
expect(screen.queryByTestId('age4-bars')).not.toBeInTheDocument()
})
})
describe('YearTimeline', () => {
it('renders one row per year from the child\'s birth to the 4th birthday', () => {
renderWithRouter(<Dashboard />)
for (const year of [2022, 2023, 2024, 2025, 2026]) {
expect(screen.getByTestId(`year-timeline-row-${year}`)).toBeInTheDocument()
}
})
it('navigates to the calendar at the clicked band\'s date', async () => {
const user = userEvent.setup()
vi.mocked(hooks.usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: '1', date: '2024-06-15', dayType: 'vacation' })],
})
)
renderWithRouter(<Dashboard />)
await user.click(screen.getByTestId('year-timeline-band-2024-0'))
expect(mockNavigate).toHaveBeenCalledWith('/calendar?date=2024-06-15')
})
})
describe('Tips', () => {
it('is hidden entirely when there are no tips', () => {
renderWithRouter(<Dashboard />)
expect(screen.queryByTestId('dashboard-tips')).not.toBeInTheDocument()
})
it('renders tip cards when tips exist', () => {
vi.mocked(hooks.useOptimizationTips).mockReturnValue(
makeOptimizationTipsResult({ data: [makeTip({ id: 'tip-1', messageKey: 'tips.use_vacation_before_expiry' })] })
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-tips')).toBeInTheDocument()
expect(screen.getAllByTestId('tip-card')).toHaveLength(1)
})
})
it('renders the folded plan-proposal section', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('plan-fold')).toBeInTheDocument()
})
it('shows overview metrics inside the collapsed "more details" section', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-more-details')).toBeInTheDocument()
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
expect(screen.getByText(/Använda dagar/)).toBeInTheDocument()
})
it('shows parental leave breakdown when data exists', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument()
// ParentalLeaveBreakdown shows an explanatory note about reserved days
// rather than a "Reserverade dagar" heading/row — see
// ParentalLeaveBreakdown.test.tsx ("displays a reserved-days explanatory
// note instead of a countdown").
expect(screen.getByTestId('reserved-days-note')).toHaveTextContent(/reserverade/i)
})
it('renders FK fetch button', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })).toBeInTheDocument()
})
it('disables FK fetch button when no child selected', () => {
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
workspaceId: 'ws-1',
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }],
selectedChildId: null,
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
})
)
renderWithRouter(<Dashboard />)
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
expect(button).toBeDisabled()
})
it('enables FK fetch button when child selected', () => {
renderWithRouter(<Dashboard />)
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
expect(button).not.toBeDisabled()
})
it('shows planned days breakdown', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
})
it('shows Swedish section headings', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metrics-title')).toHaveTextContent(/Översikt/)
expect(screen.getAllByText(/Planerade dagar/)[0]).toBeInTheDocument()
})
// The combined expiring-at-4 figure reflects the currently saved plan, while
// the Plan tab's own figure (PlanResults.tsx) reflects an unsaved proposal —
// the label must say so, so the two screens are never confused for showing
// the same thing. See PlanAge4Projection vs Age4Projection in types/entities.ts.
it('labels the combined expiring-at-4 metric as reflecting the current saved plan', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metric-expiring-combined')).toHaveTextContent(
'Förfaller vid 4 år om nuvarande plan följs (alla föräldrar)',
)
})
it('labels the per-parent expiring-at-4 metric as reflecting the current saved plan', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
combinedExpiringAtB4: 104,
},
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent(
'Förfaller vid 4 år om nuvarande plan följs – Alex',
)
})
it('displays error message if analytics fails', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: null,
error: 'Failed to load analytics',
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Failed to load analytics/)).toBeInTheDocument()
})
it('shows empty state for parental leave when data is null', () => {
vi.mocked(hooks.useParentalLeave).mockReturnValue(
makeParentalLeaveResult({
data: null,
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument()
})
it('opens QR code modal on FK fetch button click', async () => {
const user = userEvent.setup()
const mockStart = vi.fn()
vi.mocked(hooks.useScraperFetch).mockReturnValue(
makeScraperFetchResult({
start: mockStart,
})
)
renderWithRouter(<Dashboard />)
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
await user.click(button)
expect(mockStart).toHaveBeenCalledWith('c-1')
})
it('shows FK button is not disabled by default with child selected', () => {
renderWithRouter(<Dashboard />)
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
expect(button).toBeInTheDocument()
expect(button).not.toBeDisabled()
})
it('has manual fetch button enabled when child selected', () => {
renderWithRouter(<Dashboard />)
const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ })
expect(manualButton).toBeInTheDocument()
expect(manualButton).not.toBeDisabled()
})
it('disables manual fetch button when no child selected', () => {
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
workspaceId: 'ws-1',
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }],
selectedChildId: null,
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
})
)
renderWithRouter(<Dashboard />)
const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ })
expect(manualButton).toBeDisabled()
})
it('renders QRCodeModal when scraper is active', () => {
vi.mocked(hooks.useScraperFetch).mockReturnValue(
makeScraperFetchResult({
sessionId: 'session1',
pollData: { status: 'authenticating' },
isRunning: true,
})
)
renderWithRouter(<Dashboard />)
// QRCodeModal is rendered inside Dashboard
// Verify it was constructed with proper props
expect(screen.getByText(/Hämta från Försäkringskassan/)).toBeInTheDocument()
})
it('renders planned days breakdown with multiple day types', () => {
vi.mocked(hooks.usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({ id: '1', date: '2026-06-15' }),
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'sjukpenning' }),
makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'lagsta' }),
],
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
})
it('shows analytics loading state', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: null,
isLoading: true,
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
})
it('shows planned days loading state', () => {
vi.mocked(hooks.usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: null,
isLoading: true,
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
})
it('renders with zero planned days', () => {
vi.mocked(hooks.usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [],
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
})
it('displays metrics with expiring days at B4', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [],
combinedDaysUsed: 50,
combinedDaysLeft: 200,
combinedExpiringAtB4: 104,
},
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
})
it('shows only the combined expiring figure for a single-parent workspace', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104, savableCapAtB4: 96 },
],
combinedDaysUsed: 30,
combinedDaysLeft: 210,
combinedExpiringAtB4: 104,
},
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metric-expiring-combined')).toBeInTheDocument()
expect(screen.queryByTestId('metric-expiring-parent-pd-1')).not.toBeInTheDocument()
})
it('breaks down expiring days per parent alongside the combined total', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
combinedExpiringAtB4: 104,
},
})
)
renderWithRouter(<Dashboard />)
const alexMetric = screen.getByTestId('metric-expiring-parent-pd-1')
const samMetric = screen.getByTestId('metric-expiring-parent-pd-2')
const combinedMetric = screen.getByTestId('metric-expiring-combined')
expect(alexMetric).toHaveTextContent('Alex')
expect(alexMetric).toHaveTextContent('60')
expect(samMetric).toHaveTextContent('Sam')
expect(samMetric).toHaveTextContent('44')
expect(combinedMetric).toHaveTextContent('104')
})
it('falls back to a numbered parent label while members are still loading', () => {
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [],
isLoading: true,
})
)
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
combinedExpiringAtB4: 104,
},
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Förälder 1')
expect(screen.getByTestId('metric-expiring-parent-pd-2')).toHaveTextContent('Förälder 2')
})
it('falls back to a numbered parent label for a projection with no matching member', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
{ personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
combinedExpiringAtB4: 104,
},
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Alex')
expect(screen.getByTestId('metric-expiring-parent-pd-unknown')).toHaveTextContent('Förälder 2')
})
describe('manual balance entry — parent picker', () => {
it('shows a parent picker before the form when the workspace has two members', async () => {
const user = userEvent.setup()
renderWithRouter(<Dashboard />)
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
expect(screen.getByTestId('dashboard-balance-parent-picker')).toBeInTheDocument()
expect(screen.getByTestId('dashboard-balance-parent-pd-1')).toHaveTextContent('Alex')
expect(screen.getByTestId('dashboard-balance-parent-pd-2')).toHaveTextContent('Sam')
})
it('opens the form directly, with no picker, for a solo workspace', async () => {
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [{ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }],
})
)
const user = userEvent.setup()
renderWithRouter(<Dashboard />)
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
expect(screen.queryByTestId('dashboard-balance-parent-picker')).not.toBeInTheDocument()
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
})
it('posts the chosen parent\'s uuid to saveManualFetch', async () => {
const user = userEvent.setup()
renderWithRouter(<Dashboard />)
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
await user.click(screen.getByTestId('dashboard-balance-parent-pd-2'))
expect(screen.queryByTestId('dashboard-balance-parent-picker')).not.toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: 'Spara' }))
await waitFor(() =>
expect(api.saveManualFetch).toHaveBeenCalledWith(
expect.objectContaining({ personalDataUuid: 'pd-2', workspaceId: 'ws-1', childId: 'c-1', source: 'manual' })
)
)
})
it('shows an error when saving a manual balance fails', async () => {
const user = userEvent.setup()
vi.mocked(api.saveManualFetch).mockRejectedValue(new Error('save failed'))
renderWithRouter(<Dashboard />)
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
await user.click(screen.getByTestId('dashboard-balance-parent-pd-1'))
await user.click(await screen.findByRole('button', { name: 'Spara' }))
expect(await screen.findByText('save failed')).toBeInTheDocument()
})
it('prefills the manual-entry form with the parent\'s saved balance', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue(
makeParentalLeaveFetch({
personalDataUuid: 'pd-2',
childId: 'c-1',
dubbeldagarLeft: 7,
tierData: makeTierData({ sjukpenningLeft: 65, lagstaNivaLeft: 8 }),
}),
)
const user = userEvent.setup()
renderWithRouter(<Dashboard />)
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
await user.click(screen.getByTestId('dashboard-balance-parent-pd-2'))
expect(await screen.findByText('Sams dagar kvar')).toBeInTheDocument()
expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('65')
expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('8')
expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('7')
})
it('falls back to the preset when the parent has no saved balance yet', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
const user = userEvent.setup()
renderWithRouter(<Dashboard />)
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
await user.click(screen.getByTestId('dashboard-balance-parent-pd-1'))
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
// Couple workspace -> 240-day preset, split 195:45.
expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('195')
expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('45')
expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('60')
})
})
})