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:
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user