Redesign the setup flow and consolidate the CSS into one primitives layer

Onboarding asks explicitly for one or two parents and shows one card per
parent for balances; the balance form is per parent with inline
validation; the partner can be filled from the FK deduction or added
from it. Settings and Dashboard reuse the same form.

CSS: bare buttons carry no colour any more; buttons, cards, choice
cards, fields, alerts, badges, eyebrow and modal chrome are defined once
in primitives.css. Media queries live only in responsive.css. A style
guard test blocks hex, !important and stray media queries. Bilingual
fixes: calendar selects size by content, header no longer wraps. Vitest
is capped at four workers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent e4719cb9ae
commit ae6db7e16f
105 changed files with 5683 additions and 3690 deletions
+169 -29
View File
@@ -14,6 +14,8 @@ import {
makeChildSettingsResult,
makeDayOverridesResult,
makeCalendarDaysResult,
makeParentalLeaveFetch,
makeTierData,
} from '../../tests/fixtures'
vi.mock('@/hooks')
@@ -117,7 +119,6 @@ describe('Settings', () => {
workspace: {
id: 'ws-1',
name: 'Test Workspace',
planningMode: 'solo',
createdAt: '2026-01-01',
},
workspaceId: 'ws-1',
@@ -160,12 +161,6 @@ describe('Settings', () => {
data: mockCalendarWindow,
})
)
vi.mocked(api.updateWorkspace).mockResolvedValue({
id: 'ws-1',
name: 'Test Workspace',
planningMode: 'solo',
createdAt: '2026-01-01',
})
vi.mocked(api.updateParentSettings).mockResolvedValue({
uuid: 'pd-1',
name: 'Test Parent',
@@ -180,6 +175,11 @@ describe('Settings', () => {
})
vi.mocked(api.renameWorkspaceParent).mockResolvedValue(partnerMember)
vi.mocked(api.removeWorkspaceParent).mockResolvedValue(undefined)
// Default: nothing saved yet for whoever's balance form gets opened —
// `useLatestBalance` (a real hook, imported directly by Settings.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 while workspace loads', () => {
@@ -188,7 +188,6 @@ describe('Settings', () => {
workspace: {
id: 'ws-1',
name: 'Test Workspace',
planningMode: 'solo',
createdAt: '2026-01-01',
},
workspaceId: 'ws-1',
@@ -207,12 +206,24 @@ describe('Settings', () => {
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: 'Partner' })).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()
})
it('renders the partner section where the removed planning-mode selector used to be, right after the profile section, and never renders a planning-mode selector', () => {
renderSettings()
const headings = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)
const profileIndex = headings.indexOf('Din profil')
const partnerIndex = headings.indexOf('Partner')
const vacationIndex = headings.indexOf('Semesterdagar')
expect(profileIndex).toBeGreaterThanOrEqual(0)
expect(partnerIndex).toBe(profileIndex + 1)
expect(vacationIndex).toBe(partnerIndex + 1)
expect(screen.queryByTestId('planning-mode-selector')).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Planering' })).not.toBeInTheDocument()
})
describe('vacation days table', () => {
@@ -274,25 +285,6 @@ describe('Settings', () => {
})
})
describe('planning mode', () => {
it('reflects the workspace planning mode', () => {
renderSettings()
expect(screen.getByTestId('planning-mode-solo')).toBeChecked()
})
it('saves the selected planning mode', 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', {
planningMode: 'couple',
}),
)
})
})
describe('child settings', () => {
it('prefills birth date and preschool planning days from the child', () => {
renderSettings()
@@ -356,6 +348,104 @@ describe('Settings', () => {
expect(message).toHaveTextContent('Partnerns namn är ogiltigt.')
})
describe('parent balance ("Dagar kvar")', () => {
it('opens the balance form for a solo workspace with a 480-day total', async () => {
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
})
it('prefills the form with the parent\'s saved balance instead of the preset', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue(
makeParentalLeaveFetch({
personalDataUuid: 'pd-1',
childId: 'c-1',
dubbeldagarLeft: 12,
reservedDaysLeft: 34,
tierData: makeTierData({ sjukpenningLeft: 111, lagstaNivaLeft: 22 }),
}),
)
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('111')
expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('22')
expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('12')
})
it('falls back to the preset when the parent has no saved balance yet', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
// Solo workspace -> 480-day preset, split 390:90.
expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('390')
expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('90')
})
it("falls back to the workspace's first child when no child is selected yet", async () => {
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
workspaceId: 'ws-1',
children: [{ id: 'c-9', name: 'Child 9', birth_date: CHILD_BIRTH_DATE }],
selectedChildId: null,
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
})
)
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
await screen.findByText('Dina dagar kvar')
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
fireEvent.click(saveButtons[saveButtons.length - 1]!)
await waitFor(() =>
expect(api.saveManualFetch).toHaveBeenCalledWith(
expect.objectContaining({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', childId: 'c-9', source: 'manual' })
)
)
})
it('does nothing when there is no child to attach the balance to', async () => {
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',
})
)
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
await screen.findByText('Dina dagar kvar')
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
fireEvent.click(saveButtons[saveButtons.length - 1]!)
expect(api.saveManualFetch).not.toHaveBeenCalled()
})
it('shows an error when saving a balance fails', async () => {
vi.mocked(api.saveManualFetch).mockRejectedValue(new Error('balance save failed'))
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
await screen.findByText('Dina dagar kvar')
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
fireEvent.click(saveButtons[saveButtons.length - 1]!)
expect(await screen.findByText('balance save failed')).toBeInTheDocument()
})
})
describe('workspace members', () => {
beforeEach(() => {
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
@@ -497,6 +587,56 @@ describe('Settings', () => {
const message = await screen.findByTestId('member-action-error-pd-2')
expect(message).not.toHaveTextContent('workspace.parents.remove.self')
})
it('opens the balance form for the clicked member and saves with that member\'s uuid', async () => {
vi.mocked(api.saveManualFetch).mockResolvedValue({
id: 'plf-1',
personalDataUuid: 'pd-2',
workspaceId: 'ws-1',
childId: 'c-1',
source: 'manual',
fetchedAt: '2026-01-01',
totalDays: 240,
daysUsed: 0,
daysLeft: 240,
reservedDaysTotal: 45,
reservedDaysLeft: 45,
transferableDaysLeft: 0,
dubbeldagarLeft: 60,
tierData: {
sjukpenningTotal: 195,
sjukpenningUsed: 0,
sjukpenningLeft: 195,
lagstaNivaTotal: 45,
lagstaNivaUsed: 0,
lagstaNivaLeft: 45,
},
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
})
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-2'))
expect(await screen.findByText('Sams dagar kvar')).toBeInTheDocument()
// The vacation-days table above also has per-year "Spara" buttons —
// the balance form's is the last one rendered (it's mounted at the
// very end of the page).
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
fireEvent.click(saveButtons[saveButtons.length - 1]!)
await waitFor(() =>
expect(api.saveManualFetch).toHaveBeenCalledWith(
expect.objectContaining({ personalDataUuid: 'pd-2', workspaceId: 'ws-1', childId: 'c-1', source: 'manual' })
)
)
})
it('opens the balance form for the logged-in user\'s own row too', async () => {
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
})
})
it('toggles week numbers', () => {