Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -10,6 +10,13 @@ vi.mock('@/hooks')
|
||||
vi.mock('@/context')
|
||||
vi.mock('@/services/api')
|
||||
|
||||
// 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>,
|
||||
}))
|
||||
|
||||
const renderWithRouter = (component: React.ReactElement) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>)
|
||||
}
|
||||
@@ -98,13 +105,6 @@ describe('Dashboard', () => {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as any)
|
||||
vi.mocked(hooks.useBreakPeriods).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as any)
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue({
|
||||
sessionId: null,
|
||||
pollData: null,
|
||||
@@ -124,7 +124,7 @@ describe('Dashboard', () => {
|
||||
isLoading: true,
|
||||
} as any)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Loading dashboard/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows add child form when no children', () => {
|
||||
@@ -141,13 +141,17 @@ describe('Dashboard', () => {
|
||||
it('shows overview metrics when children exist', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Days Used/)).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()
|
||||
expect(screen.getByText(/Reserverade dagar/)).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', () => {
|
||||
@@ -188,30 +192,6 @@ describe('Dashboard', () => {
|
||||
expect(screen.getByText(/Återstående till 4 års ålder/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows break periods section when periods exist', () => {
|
||||
vi.mocked(hooks.useBreakPeriods).mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 'bp-1',
|
||||
workspaceId: 'ws-1',
|
||||
name: 'Summer Break',
|
||||
startDate: '2026-06-20',
|
||||
endDate: '2026-08-31',
|
||||
targetWeeks: 8,
|
||||
createdAt: '2026-06-13T10:00:00Z',
|
||||
updatedAt: '2026-06-13T10:00:00Z',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as any)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Break Periods/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Summer Break/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays error message if analytics fails', () => {
|
||||
vi.mocked(hooks.useAnalytics).mockReturnValue({
|
||||
data: null,
|
||||
@@ -262,4 +242,227 @@ describe('Dashboard', () => {
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('submits add child form with valid data', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockCreateChild = vi.fn().mockResolvedValue({ id: 'c-2', name: 'New Child', birth_date: '2024-01-15' })
|
||||
const mockRefreshContext = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
...mockWorkspaceContext,
|
||||
children: [],
|
||||
selectedChildId: null,
|
||||
refreshContext: mockRefreshContext,
|
||||
} as any)
|
||||
|
||||
const mockApi = await import('@/services/api')
|
||||
vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild)
|
||||
|
||||
renderWithRouter(<Dashboard />)
|
||||
|
||||
const nameInput = screen.getByLabelText(/Barnets namn/)
|
||||
const birthDateInput = screen.getByTestId('birth-date-input')
|
||||
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
|
||||
|
||||
await user.type(nameInput, 'Test Child')
|
||||
await user.type(birthDateInput, '2024-01-15')
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(mockCreateChild).toHaveBeenCalledWith({
|
||||
workspace_id: 'ws-1',
|
||||
name: 'Test Child',
|
||||
birth_date: '2024-01-15',
|
||||
})
|
||||
expect(mockRefreshContext).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error when add child form submission fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockCreateChild = vi.fn().mockRejectedValue(new Error('API error'))
|
||||
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
...mockWorkspaceContext,
|
||||
children: [],
|
||||
selectedChildId: null,
|
||||
refreshContext: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const mockApi = await import('@/services/api')
|
||||
vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild)
|
||||
|
||||
renderWithRouter(<Dashboard />)
|
||||
|
||||
const nameInput = screen.getByLabelText(/Barnets namn/)
|
||||
const birthDateInput = screen.getByTestId('birth-date-input')
|
||||
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
|
||||
|
||||
await user.type(nameInput, 'Test Child')
|
||||
await user.type(birthDateInput, '2024-01-15')
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(screen.getByText(/API error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables submit button during form submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockCreateChild = vi.fn().mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ id: 'c-2' }), 100))
|
||||
)
|
||||
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
...mockWorkspaceContext,
|
||||
children: [],
|
||||
selectedChildId: null,
|
||||
refreshContext: vi.fn().mockResolvedValue(undefined),
|
||||
} as any)
|
||||
|
||||
const mockApi = await import('@/services/api')
|
||||
vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild)
|
||||
|
||||
renderWithRouter(<Dashboard />)
|
||||
|
||||
const nameInput = screen.getByLabelText(/Barnets namn/)
|
||||
const birthDateInput = screen.getByTestId('birth-date-input')
|
||||
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
|
||||
|
||||
await user.type(nameInput, 'Test Child')
|
||||
await user.type(birthDateInput, '2024-01-15')
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('validates add child form with workspace check', async () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
...mockWorkspaceContext,
|
||||
children: [],
|
||||
selectedChildId: null,
|
||||
workspace: null,
|
||||
refreshContext: vi.fn(),
|
||||
} as any)
|
||||
|
||||
renderWithRouter(<Dashboard />)
|
||||
|
||||
const nameInput = screen.getByLabelText(/Barnets namn/)
|
||||
const birthDateInput = screen.getByTestId('birth-date-input')
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(nameInput, 'Test Child')
|
||||
await user.type(birthDateInput, '2024-01-15')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(screen.getByText(/Fyll i alla fält/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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({
|
||||
...mockWorkspaceContext,
|
||||
selectedChildId: null,
|
||||
} as any)
|
||||
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({
|
||||
sessionId: 'session1',
|
||||
pollData: { status: 'in_progress' },
|
||||
resolvedIds: null,
|
||||
error: null,
|
||||
isUnauthorized: false,
|
||||
start: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isRunning: true,
|
||||
isTerminal: false,
|
||||
} as any)
|
||||
|
||||
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({
|
||||
data: [
|
||||
{ id: '1', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, createdAt: '', updatedAt: '' },
|
||||
{ id: '2', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-16', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '', updatedAt: '' },
|
||||
{ id: '3', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-17', dayType: 'lagsta', isDubbeldag: false, createdAt: '', updatedAt: '' },
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as any)
|
||||
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows analytics loading state', () => {
|
||||
vi.mocked(hooks.useAnalytics).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
} as any)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows planned days loading state', () => {
|
||||
vi.mocked(hooks.usePlannedDays).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as any)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with zero planned days', () => {
|
||||
vi.mocked(hooks.usePlannedDays).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as any)
|
||||
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays metrics with expiring days at B4', () => {
|
||||
vi.mocked(hooks.useAnalytics).mockReturnValue({
|
||||
data: {
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'c-1',
|
||||
parentProjections: [],
|
||||
combinedDaysUsed: 50,
|
||||
combinedDaysLeft: 200,
|
||||
combinedExpiringAtB4: 104,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
} as any)
|
||||
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user