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
@@ -92,7 +92,9 @@ describe('CalendarWeek', () => {
selectedDates: new Set<string>(),
parentName: 'Test Parent',
personalDataUuid: 'parent-1',
isCurrentMonth: true,
// defaultProps.weekDates is 2026-01-05..2026-01-11, all January.
displayedYear: 2026,
displayedMonth: 0,
showWeekNumbers: false,
onDayClick: vi.fn(),
onPointerDown: vi.fn(),
@@ -136,18 +138,24 @@ describe('CalendarWeek', () => {
})
})
it('marks all days as current month when isCurrentMonth is true', () => {
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
it('marks all days as current month when they match the displayed year/month', () => {
render(<CalendarWeek {...defaultProps} />)
defaultProps.weekDates.forEach((date) => {
expect(screen.getByTestId(`calendar-day-${date}`)).toHaveClass('current-month')
})
})
it('marks days outside the current month with current-month class set to false', () => {
it('marks a leading week\'s in-month days as current month and its previous-month days as not, per date — not per weekDates[0]', () => {
// Regression guard: this used to compare every day in the week
// against `weekDates[0]`'s own month, so an entire leading week
// (partly previous month) was judged by its FIRST day alone. A week
// spanning Dec 29 – Jan 4, displayed as January, must activate the
// January days (01-01..01-04) and deactivate the December ones
// (12-29..12-31) — not the other way around.
const prevMonthDates = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
render(<CalendarWeek {...defaultProps} weekDates={prevMonthDates} isCurrentMonth={true} />)
expect(screen.getByTestId('calendar-day-2025-12-29')).toHaveClass('current-month')
expect(screen.getByTestId('calendar-day-2026-01-01')).not.toHaveClass('current-month')
render(<CalendarWeek {...defaultProps} weekDates={prevMonthDates} />)
expect(screen.getByTestId('calendar-day-2025-12-29')).not.toHaveClass('current-month')
expect(screen.getByTestId('calendar-day-2026-01-01')).toHaveClass('current-month')
})
it('highlights today when date matches current date', () => {
@@ -205,6 +213,28 @@ describe('CalendarWeek', () => {
expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument()
})
it('resolves a partner band label from parentNames, not the generic "Partner" string', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'partner-uuid' }),
]
const parentNames = new Map([
['parent-1', 'Test Parent'],
['partner-uuid', 'Erik'],
])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentNames={parentNames} />)
expect(screen.getByText(/Test Parent · Semester/)).toBeInTheDocument()
expect(screen.getByText(/Erik · Semester/)).toBeInTheDocument()
expect(screen.queryByText(/Partner · Semester/)).not.toBeInTheDocument()
})
it('falls back to the generic "Partner" string when the band\'s parentUuid matches no known member', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'stale-uuid' })]
const parentNames = new Map([['parent-1', 'Test Parent']])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentNames={parentNames} />)
expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument()
})
it('renders different day types with localized labels', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', dayType: 'sjukpenning' }),
@@ -510,66 +540,211 @@ describe('CalendarWeek', () => {
expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument()
expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument()
})
it('stacks overlapping bands at different bottom offsets within the same grid row, not different rows', () => {
// Regression guard for the overlay rework: lanes used to be separate
// grid rows (gridRow 2, 3, ...) below the day cells. Now every band
// shares gridRow 1 with the day cells and is stacked purely via its
// inline `bottom` offset, so overlapping bands must render at
// DIFFERENT `bottom` values while both staying in row 1.
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '3', date: '2026-01-05', parentPersonalDataUuid: 'other-parent' }),
pd({ id: '4', date: '2026-01-06', parentPersonalDataUuid: 'other-parent' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
const bands = screen.getAllByTestId(/^calendar-band-/)
expect(bands).toHaveLength(2)
bands.forEach((band) => expect(band.style.gridRow).toBe('1'))
const bottoms = bands.map((band) => band.style.bottom)
expect(new Set(bottoms).size).toBe(2)
// Both lanes share the same band height.
expect(new Set(bands.map((band) => band.style.height)).size).toBe(1)
})
})
describe('band overlay: bands render on the day-cell row, not in a gutter below it', () => {
it('places a band at gridRow 1 with a grid-column span matching its start/end columns', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-06' }),
pd({ id: '2', date: '2026-01-07' }),
pd({ id: '3', date: '2026-01-08' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
expect(band.style.gridRow).toBe('1')
// 01-06/07/08 are columns 2/3/4 (1-indexed, no week-number offset).
expect(band.style.gridColumn).toBe('2 / 5')
})
it('accounts for the week-number column offset in the band\'s grid-column span', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} showWeekNumbers={true} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
// 01-05 is column 1 of the week, shifted right by the week-number column.
expect(band.style.gridColumn).toBe('2 / 3')
})
it('reserves bottom space on the week container via --band-reserved-height when a band is present', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const week = container.querySelector('.calendar-week') as HTMLElement
expect(week.style.getPropertyValue('--band-reserved-height')).not.toBe('0px')
expect(week.style.getPropertyValue('--band-reserved-height')).toMatch(/^\d+px$/)
})
it('reserves zero space on the week container when there are no bands', () => {
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={[]} />)
const week = container.querySelector('.calendar-week') as HTMLElement
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('0px')
})
it('still fires the day cell\'s onClick/onPointerDown/onPointerEnter for a date under a band', () => {
// The band overlays the day cell (same grid row); the day cell must
// stay reachable for its own click/drag-select handlers underneath.
const onDayClick = vi.fn()
const onPointerDown = vi.fn()
const onPointerEnter = vi.fn()
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
render(
<CalendarWeek
{...defaultProps}
plannedDays={plannedDays}
onDayClick={onDayClick}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
/>,
)
const dayCell = screen.getByTestId('calendar-day-2026-01-05')
fireEvent.click(dayCell)
fireEvent.pointerDown(dayCell)
fireEvent.pointerEnter(dayCell)
expect(onDayClick).toHaveBeenCalledWith('2026-01-05')
expect(onPointerDown).toHaveBeenCalledWith('2026-01-05')
expect(onPointerEnter).toHaveBeenCalledWith('2026-01-05')
})
it('still fires onBandClick for a band overlaying a date, alongside the day cell staying clickable', () => {
const onBandClick: (info: BandClickInfo) => void = vi.fn()
const onDayClick = vi.fn()
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' })]
render(
<CalendarWeek
{...defaultProps}
plannedDays={plannedDays}
onBandClick={onBandClick}
onDayClick={onDayClick}
/>,
)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(onBandClick).toHaveBeenCalledWith({
wishId: 'wish-1',
parentPersonalDataUuid: 'parent-1',
startDate: '2026-01-05',
endDate: '2026-01-05',
})
// Clicking the band must not also trigger the day cell's own click.
expect(onDayClick).not.toHaveBeenCalled()
})
})
describe('event handlers', () => {
it('calls onDayClick when clicking a day in current month', () => {
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} />)
fireEvent.click(screen.getByTestId('calendar-day-2026-01-05'))
expect(defaultProps.onDayClick).toHaveBeenCalledWith('2026-01-05')
})
it('does not call onDayClick for days outside the week\'s reference month', () => {
it('calls onDayClick for a leading week\'s day that belongs to the displayed month (crossing the month boundary)', () => {
// Regression guard for the drag-select gating bug: a leading week's
// in-month days must be clickable even though `weekDates[0]` belongs
// to the previous month.
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onDayClick = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} isCurrentMonth={true} onDayClick={onDayClick} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onDayClick={onDayClick} />)
fireEvent.click(screen.getByTestId('calendar-day-2026-01-01'))
expect(onDayClick).not.toHaveBeenCalled()
expect(onDayClick).toHaveBeenCalledWith('2026-01-01')
})
it('does not call onDayClick when isCurrentMonth is false', () => {
it('still calls onDayClick for a day belonging to the adjacent (non-displayed) month in the same leading week', () => {
// Regression guard for the drag-across-month-boundary bug: gating
// click/pointer handlers on `dayIsCurrentMonth` per-cell killed every
// adjacent-month cell's interactivity, which broke a drag that
// crosses a month boundary (see Calendar.tsx's pointerup handler /
// drag-create-delete.spec.ts). `isCurrentMonth` still only drives the
// dimmed `current-month`/disabled styling; clicking a visible
// adjacent-month day is a deliberate, supported way to plan that day.
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onDayClick = vi.fn()
render(<CalendarWeek {...defaultProps} isCurrentMonth={false} onDayClick={onDayClick} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onDayClick={onDayClick} />)
fireEvent.click(screen.getByTestId('calendar-day-2025-12-29'))
expect(onDayClick).toHaveBeenCalledWith('2025-12-29')
})
it('still calls onDayClick when no day in the week matches the displayed year/month', () => {
const onDayClick = vi.fn()
render(<CalendarWeek {...defaultProps} displayedMonth={5} onDayClick={onDayClick} />)
fireEvent.click(screen.getByTestId('calendar-day-2026-01-05'))
expect(onDayClick).not.toHaveBeenCalled()
expect(onDayClick).toHaveBeenCalledWith('2026-01-05')
})
it('calls onPointerDown when starting drag on a current-month day', () => {
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} />)
fireEvent.pointerDown(screen.getByTestId('calendar-day-2026-01-05'))
expect(defaultProps.onPointerDown).toHaveBeenCalledWith('2026-01-05')
})
it('does not call onPointerDown for days outside the week\'s reference month', () => {
it('calls onPointerDown for a leading week\'s day that belongs to the displayed month (crossing the month boundary)', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerDown = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} isCurrentMonth={true} onPointerDown={onPointerDown} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerDown={onPointerDown} />)
fireEvent.pointerDown(screen.getByTestId('calendar-day-2026-01-01'))
expect(onPointerDown).not.toHaveBeenCalled()
expect(onPointerDown).toHaveBeenCalledWith('2026-01-01')
})
it('still calls onPointerDown for a day belonging to the adjacent (non-displayed) month in the same leading week', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerDown = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerDown={onPointerDown} />)
fireEvent.pointerDown(screen.getByTestId('calendar-day-2025-12-29'))
expect(onPointerDown).toHaveBeenCalledWith('2025-12-29')
})
it('calls onPointerEnter when dragging through a current-month day', () => {
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} />)
fireEvent.pointerEnter(screen.getByTestId('calendar-day-2026-01-06'))
expect(defaultProps.onPointerEnter).toHaveBeenCalledWith('2026-01-06')
})
it('does not call onPointerEnter for days outside the week\'s reference month', () => {
it('calls onPointerEnter for a leading week\'s day that belongs to the displayed month (crossing the month boundary)', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerEnter = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} isCurrentMonth={true} onPointerEnter={onPointerEnter} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerEnter={onPointerEnter} />)
fireEvent.pointerEnter(screen.getByTestId('calendar-day-2026-01-01'))
expect(onPointerEnter).not.toHaveBeenCalled()
expect(onPointerEnter).toHaveBeenCalledWith('2026-01-01')
})
it('does not call event handlers when isCurrentMonth is false', () => {
it('still calls onPointerEnter for a day belonging to the adjacent (non-displayed) month in the same leading week', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerEnter = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerEnter={onPointerEnter} />)
fireEvent.pointerEnter(screen.getByTestId('calendar-day-2025-12-29'))
expect(onPointerEnter).toHaveBeenCalledWith('2025-12-29')
})
it('still calls event handlers when no day in the week matches the displayed year/month, so a drag can cross a month boundary', () => {
// A drag whose anchor is the last day of one month and whose current
// cell is the first day of the next crosses a week row where NONE of
// the days match the still-displayed month — that cell must still be
// reachable, or the drag can never enter the new month at all.
const onDayClick = vi.fn()
const onPointerDown = vi.fn()
const onPointerEnter = vi.fn()
render(
<CalendarWeek
{...defaultProps}
isCurrentMonth={false}
displayedMonth={5}
onDayClick={onDayClick}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
@@ -579,23 +754,23 @@ describe('CalendarWeek', () => {
fireEvent.click(dayElement)
fireEvent.pointerDown(dayElement)
fireEvent.pointerEnter(dayElement)
expect(onDayClick).not.toHaveBeenCalled()
expect(onPointerDown).not.toHaveBeenCalled()
expect(onPointerEnter).not.toHaveBeenCalled()
expect(onDayClick).toHaveBeenCalledWith('2026-01-05')
expect(onPointerDown).toHaveBeenCalledWith('2026-01-05')
expect(onPointerEnter).toHaveBeenCalledWith('2026-01-05')
})
})
describe('month boundary edge cases', () => {
it('correctly identifies days at month boundaries', () => {
const weekDates = ['2026-01-26', '2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31', '2026-02-01']
render(<CalendarWeek {...defaultProps} weekDates={weekDates} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDates} />)
expect(screen.getByTestId('calendar-day-2026-01-31')).toHaveClass('current-month')
expect(screen.getByTestId('calendar-day-2026-02-01')).not.toHaveClass('current-month')
})
it('handles start of month week', () => {
const weekDates = ['2026-01-26', '2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31', '2026-02-01']
render(<CalendarWeek {...defaultProps} weekDates={weekDates} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDates} />)
weekDates.forEach((date) => {
expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument()
})
@@ -603,7 +778,7 @@ describe('CalendarWeek', () => {
it('handles end of month week', () => {
const weekDates = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
render(<CalendarWeek {...defaultProps} weekDates={weekDates} isCurrentMonth={true} />)
render(<CalendarWeek {...defaultProps} weekDates={weekDates} />)
weekDates.forEach((date) => {
expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument()
})