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:
2026-09-14 02:33:03 +02:00
parent 3ca1418d94
commit 76b1e712b9
218 changed files with 29867 additions and 6085 deletions
+415
View File
@@ -0,0 +1,415 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Settings } from './Settings'
import * as hooks from '@/hooks'
import { useWorkspace } from '@/context'
import * as api from '@/services/api'
import { LanguageProvider } from '@/i18n'
vi.mock('@/hooks')
vi.mock('@/context')
vi.mock('@/services/api')
// Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router tree
// to work. Settings.tsx only cares that its content renders inside Layout, so
// stub Layout out — same pattern used by Calendar.test.tsx.
vi.mock('@/components/layout/Layout', () => ({
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
function renderSettings() {
return render(<Settings />)
}
// The language selector tests exercise real locale switching, which needs an
// actual <LanguageProvider> in the tree (the other tests in this file render
// Settings unwrapped and get the i18n module's non-reactive default context,
// which is fine since they never touch the locale).
function renderSettingsWithLanguageProvider() {
return render(
<LanguageProvider>
<Settings />
</LanguageProvider>,
)
}
const CURRENT_YEAR = new Date().getFullYear()
// Turns 4 three years from now -> vacation table should span CURRENT_YEAR..CURRENT_YEAR+3
const CHILD_BIRTH_DATE = `${CURRENT_YEAR - 1}-01-01`
describe('Settings', () => {
const mockWorkspaceContext = {
workspace: {
id: 'ws-1',
name: 'Test Workspace',
planningGoal: 'balanced' as const,
planningMode: 'solo' as const,
createdAt: '2026-01-01',
},
isLoading: false,
error: null,
workspaceId: 'ws-1',
setWorkspaceId: vi.fn(),
refetch: vi.fn(),
children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }],
selectedChildId: 'c-1',
selectChild: vi.fn(),
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
refreshContext: vi.fn(),
}
const mockVacationDays = [
{
id: 'vd-1',
personalDataUuid: 'pd-1',
workspaceId: 'ws-1',
year: CURRENT_YEAR,
totalDays: 10,
savedDaysIn: 2,
createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-01T00:00:00',
},
]
const mockChildDetails = {
id: 'c-1',
workspaceId: 'ws-1',
name: 'Child 1',
birthDate: CHILD_BIRTH_DATE,
preschoolStartDate: null,
preschoolPlanningDaysPerYear: 6,
createdAt: '2026-01-01T00:00:00',
}
let mockUpsertVacationDays: ReturnType<typeof vi.fn>
let mockUpdateChildDetails: ReturnType<typeof vi.fn>
let mockUpsertDayOverride: ReturnType<typeof vi.fn>
let mockRemoveDayOverride: ReturnType<typeof vi.fn>
const mockCalendarWindow = {
startYear: CURRENT_YEAR,
endYear: CURRENT_YEAR + 3,
minYear: 2026,
maxYear: 2035,
days: [],
}
beforeEach(() => {
vi.clearAllMocks()
mockUpsertVacationDays = vi.fn().mockResolvedValue(undefined)
mockUpdateChildDetails = vi.fn().mockResolvedValue(undefined)
mockUpsertDayOverride = vi.fn().mockResolvedValue(undefined)
mockRemoveDayOverride = vi.fn().mockResolvedValue(undefined)
vi.mocked(useWorkspace).mockReturnValue(mockWorkspaceContext as any)
vi.mocked(hooks.useVacationDays).mockReturnValue({
data: mockVacationDays,
isLoading: false,
error: null,
upsert: mockUpsertVacationDays,
} as any)
vi.mocked(hooks.useShowWeekNumbers).mockReturnValue([false, vi.fn()] as any)
vi.mocked(hooks.useChildSettings).mockReturnValue({
data: mockChildDetails,
isLoading: false,
error: null,
update: mockUpdateChildDetails,
} as any)
vi.mocked(hooks.useDayOverrides).mockReturnValue({
data: [],
isLoading: false,
error: null,
upsert: mockUpsertDayOverride,
remove: mockRemoveDayOverride,
} as any)
vi.mocked(hooks.useCalendarDays).mockReturnValue({
data: mockCalendarWindow,
isLoading: false,
error: null,
} as any)
vi.mocked(api.updateWorkspace).mockResolvedValue({ ...mockWorkspaceContext.workspace } as any)
vi.mocked(api.updateParentSettings).mockResolvedValue({} as any)
vi.mocked(api.addPartner).mockResolvedValue({} as any)
})
it('renders loading spinner while workspace loads', () => {
vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspaceContext, isLoading: true } as any)
renderSettings()
expect(screen.getByText(/Laddar inställningar/)).toBeInTheDocument()
})
it('renders consistently Swedish section headings', () => {
renderSettings()
expect(screen.getByRole('heading', { name: 'Barn' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Din profil' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Planering' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Semesterdagar' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Kalender' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Undantag i kalendern' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Röda dagar' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Partner' })).toBeInTheDocument()
})
describe('vacation days table', () => {
it('renders one row per year from the current year through the year the child turns 4', () => {
renderSettings()
for (let year = CURRENT_YEAR; year <= CURRENT_YEAR + 3; year++) {
expect(screen.getByTestId(`vacation-year-row-${year}`)).toBeInTheDocument()
}
expect(screen.queryByTestId(`vacation-year-row-${CURRENT_YEAR + 4}`)).not.toBeInTheDocument()
})
it('prefills a year that already has saved data', () => {
renderSettings()
const totalInput = screen.getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement
const savedInput = screen.getByTestId(`vacation-saved-days-${CURRENT_YEAR}`) as HTMLInputElement
expect(totalInput.value).toBe('10')
expect(savedInput.value).toBe('2')
})
it('defaults a year with no saved data to 25 vacation days', () => {
renderSettings()
const totalInput = screen.getByTestId(`vacation-total-days-${CURRENT_YEAR + 1}`) as HTMLInputElement
expect(totalInput.value).toBe('25')
})
it('saves the edited year with its total days when Spara is clicked', async () => {
renderSettings()
const year = CURRENT_YEAR + 1
const totalInput = screen.getByTestId(`vacation-total-days-${year}`)
fireEvent.change(totalInput, { target: { value: '30' } })
fireEvent.click(screen.getByTestId(`vacation-save-${year}`))
await waitFor(() =>
expect(mockUpsertVacationDays).toHaveBeenCalledWith({
personalDataUuid: 'pd-1',
workspaceId: 'ws-1',
year,
totalDays: 30,
savedDaysIn: 0,
}),
)
})
it('saves saved-days-in alongside total days', async () => {
renderSettings()
const savedInput = screen.getByTestId(`vacation-saved-days-${CURRENT_YEAR}`)
fireEvent.change(savedInput, { target: { value: '7' } })
fireEvent.click(screen.getByTestId(`vacation-save-${CURRENT_YEAR}`))
await waitFor(() =>
expect(mockUpsertVacationDays).toHaveBeenCalledWith({
personalDataUuid: 'pd-1',
workspaceId: 'ws-1',
year: CURRENT_YEAR,
totalDays: 10,
savedDaysIn: 7,
}),
)
})
})
describe('planning mode', () => {
it('reflects the workspace planning mode', () => {
renderSettings()
expect(screen.getByTestId('planning-mode-solo')).toBeChecked()
})
it('saves the selected planning mode together with the planning goal', async () => {
renderSettings()
fireEvent.click(screen.getByTestId('planning-mode-couple'))
fireEvent.click(screen.getByTestId('save-planning-goal-button'))
await waitFor(() =>
expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', {
planningGoal: 'balanced',
planningMode: 'couple',
}),
)
})
})
describe('child settings', () => {
it('prefills birth date and preschool planning days from the child', () => {
renderSettings()
const birthInput = screen.getByTestId('birth-date-input') as HTMLInputElement
const preschoolInput = screen.getByTestId('vacation-days-field') as HTMLInputElement
expect(birthInput.value).toBe(CHILD_BIRTH_DATE)
expect(preschoolInput.value).toBe('6')
})
it('saves birth date and preschool planning days together', async () => {
renderSettings()
const preschoolInput = screen.getByTestId('vacation-days-field')
fireEvent.change(preschoolInput, { target: { value: '10' } })
fireEvent.click(screen.getByTestId('save-child-button'))
await waitFor(() =>
expect(mockUpdateChildDetails).toHaveBeenCalledWith({
birthDate: CHILD_BIRTH_DATE,
preschoolPlanningDaysPerYear: 10,
}),
)
})
})
it('saves parent profile fields', async () => {
renderSettings()
fireEvent.change(screen.getByTestId('salary-type'), { target: { value: 'monthly' } })
fireEvent.change(screen.getByTestId('employment-status'), { target: { value: 'employed' } })
fireEvent.click(screen.getByTestId('save-parent-button'))
await waitFor(() =>
expect(api.updateParentSettings).toHaveBeenCalledWith('pd-1', {
salary_type: 'monthly',
employment_status: 'employed',
}),
)
})
it('adds a partner', async () => {
renderSettings()
fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'Partner Name' } })
fireEvent.click(screen.getByTestId('add-partner-button'))
await waitFor(() => expect(api.addPartner).toHaveBeenCalledWith('ws-1', 'Partner Name'))
})
it('toggles week numbers', () => {
const setShowWeekNumbers = vi.fn()
vi.mocked(hooks.useShowWeekNumbers).mockReturnValue([false, setShowWeekNumbers] as any)
renderSettings()
fireEvent.click(screen.getByTestId('show-week-numbers-checkbox'))
expect(setShowWeekNumbers).toHaveBeenCalledWith(true)
})
describe('language selector', () => {
// Node's own experimental `localStorage` global shadows jsdom's working
// implementation in this environment and throws on every call — stub a
// real in-memory Storage, matching the idiom used in
// WorkspaceContext.test.tsx and useCalendarPreferences.test.ts.
function createStorageMock(): Storage {
let store: Record<string, string> = {}
return {
getItem: (key: string) => (key in store ? store[key]! : null),
setItem: (key: string, value: string) => {
store[key] = value
},
removeItem: (key: string) => {
delete store[key]
},
clear: () => {
store = {}
},
key: (index: number) => Object.keys(store)[index] ?? null,
get length() {
return Object.keys(store).length
},
}
}
beforeEach(() => {
vi.stubGlobal('localStorage', createStorageMock())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('is labelled understandably regardless of the active language', () => {
renderSettingsWithLanguageProvider()
expect(screen.getByText('Språk / Language')).toBeInTheDocument()
expect(screen.getByText('Välj språk / Select language')).toBeInTheDocument()
})
it('offers automatic, Swedish, and English options', () => {
renderSettingsWithLanguageProvider()
const select = screen.getByTestId('language-select') as HTMLSelectElement
const optionValues = Array.from(select.options).map((o) => o.value)
expect(optionValues).toEqual(['auto', 'sv', 'en'])
})
it('defaults to "auto" when no explicit preference is stored', () => {
renderSettingsWithLanguageProvider()
const select = screen.getByTestId('language-select') as HTMLSelectElement
expect(select.value).toBe('auto')
})
it('switching to English updates the UI immediately, without a reload', () => {
renderSettingsWithLanguageProvider()
expect(screen.getByRole('heading', { name: 'Inställningar' })).toBeInTheDocument()
fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } })
expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Inställningar' })).not.toBeInTheDocument()
})
it('persists the explicit choice to localStorage', () => {
renderSettingsWithLanguageProvider()
fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } })
expect(localStorage.getItem('language_preference')).toBe('en')
})
it('going back to "Automatic" clears the stored preference', () => {
renderSettingsWithLanguageProvider()
fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } })
fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'auto' } })
expect(localStorage.getItem('language_preference')).toBeNull()
})
})
describe('red days list (§3.8)', () => {
it('passes the fetched calendar window through to the list, four years at a time', () => {
vi.mocked(hooks.useCalendarDays).mockReturnValue({
data: {
startYear: CURRENT_YEAR,
endYear: CURRENT_YEAR + 3,
minYear: 2026,
maxYear: 2035,
days: [
{
date: `${CURRENT_YEAR}-01-01`,
name: 'Nyårsdagen',
dayType: 'red_day',
isOverride: false,
overrideIsWorkingDay: null,
isFree: true,
},
],
},
isLoading: false,
error: null,
} as any)
renderSettings()
expect(screen.getByTestId('red-days-range')).toHaveTextContent(`${CURRENT_YEAR}–${CURRENT_YEAR + 3}`)
expect(screen.getByTestId(`red-days-item-${CURRENT_YEAR}-01-01`)).toHaveTextContent('Nyårsdagen')
})
it('requests the next 4-year window when paginating forward', () => {
renderSettings()
fireEvent.click(screen.getByTestId('red-days-next'))
expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR + 4, 4)
})
it('requests the previous 4-year window when paginating backward', () => {
// minYear well below the current window so the "previous" button isn't disabled.
vi.mocked(hooks.useCalendarDays).mockReturnValue({
data: { ...mockCalendarWindow, minYear: 2000 },
isLoading: false,
error: null,
} as any)
renderSettings()
fireEvent.click(screen.getByTestId('red-days-prev'))
expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR - 4, 4)
})
})
})