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:
@@ -0,0 +1,889 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { CalendarWeek } from './CalendarWeek'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, _data?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'calendar.partner': 'Partner',
|
||||
'leaveType.vacation': 'Semester',
|
||||
'leaveType.sjukpenning': 'Sjukpenning',
|
||||
'leaveType.lagsta': 'Lägsta nivå',
|
||||
}
|
||||
return translations[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./CalendarDay', () => ({
|
||||
CalendarDay: ({
|
||||
date,
|
||||
isSelected,
|
||||
isCurrentMonth,
|
||||
isToday,
|
||||
onClick,
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
dayType,
|
||||
holidayName,
|
||||
isOverride,
|
||||
overrideIsWorkingDay,
|
||||
}: any) => (
|
||||
<div
|
||||
data-testid={`calendar-day-${date}`}
|
||||
className={`${isSelected ? 'selected' : ''} ${isCurrentMonth ? 'current-month' : ''} ${isToday ? 'today' : ''}`}
|
||||
data-day-type={dayType ?? ''}
|
||||
data-holiday-name={holidayName ?? ''}
|
||||
data-is-override={isOverride ? 'true' : 'false'}
|
||||
data-override-is-working-day={overrideIsWorkingDay === null || overrideIsWorkingDay === undefined ? '' : String(overrideIsWorkingDay)}
|
||||
onClick={onClick}
|
||||
onPointerDown={() => onPointerDown?.(date)}
|
||||
onPointerEnter={() => onPointerEnter?.(date)}
|
||||
>
|
||||
{date}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./CalendarEvent', () => ({
|
||||
CalendarEvent: ({ leaveType, isDubbeldag, parentLabel }: any) => (
|
||||
<div data-testid="calendar-event" className={isDubbeldag ? 'dubbeldag' : ''}>
|
||||
{parentLabel} · {leaveType}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CalendarWeek', () => {
|
||||
const defaultProps = {
|
||||
weekDates: ['2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-10', '2026-01-11'],
|
||||
plannedDays: [],
|
||||
selectedDates: new Set<string>(),
|
||||
parentName: 'Test Parent',
|
||||
personalDataUuid: 'parent-1',
|
||||
isCurrentMonth: true,
|
||||
showWeekNumbers: false,
|
||||
onDayClick: vi.fn(),
|
||||
onPointerDown: vi.fn(),
|
||||
onPointerEnter: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('week number display', () => {
|
||||
it('shows week number when showWeekNumbers is true', () => {
|
||||
render(<CalendarWeek {...defaultProps} showWeekNumbers={true} />)
|
||||
// Week containing 2026-01-05 is week 2
|
||||
expect(screen.getByText(/v\.\d+/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides week number when showWeekNumbers is false', () => {
|
||||
render(<CalendarWeek {...defaultProps} showWeekNumbers={false} />)
|
||||
const weekNumberElement = screen.queryByText(/^v\.\d+$/)
|
||||
// The week number is rendered only inside a specific class; when showWeekNumbers is false,
|
||||
// the conditional doesn't render it
|
||||
expect(weekNumberElement).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays correct week number for a given week', () => {
|
||||
// 2026-01-05 is a Monday in week 2
|
||||
render(<CalendarWeek {...defaultProps} weekDates={defaultProps.weekDates} showWeekNumbers={true} />)
|
||||
const weekNumberText = screen.getByText('v.2')
|
||||
expect(weekNumberText).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('day rendering', () => {
|
||||
it('renders all 7 days of the week', () => {
|
||||
render(<CalendarWeek {...defaultProps} />)
|
||||
defaultProps.weekDates.forEach((date) => {
|
||||
expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders each day with correct date string', () => {
|
||||
render(<CalendarWeek {...defaultProps} />)
|
||||
defaultProps.weekDates.forEach((date) => {
|
||||
expect(screen.getByText(date)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('marks all days as current month when isCurrentMonth is true', () => {
|
||||
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
|
||||
defaultProps.weekDates.forEach((date) => {
|
||||
const dayElement = screen.getByTestId(`calendar-day-${date}`)
|
||||
expect(dayElement).toHaveClass('current-month')
|
||||
})
|
||||
})
|
||||
|
||||
it('marks days outside the current month with current-month class set to false', () => {
|
||||
// When a week spans month boundaries, days are marked based on their month vs the first day's month
|
||||
// 2025-12-29 is first, so 12/29-31 are marked as current (Dec), 01/01-04 are marked as outside (Jan)
|
||||
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}
|
||||
/>,
|
||||
)
|
||||
// Days from previous month (Dec 2025) should have current-month class (first date determines the month)
|
||||
const decDayElement = screen.getByTestId('calendar-day-2025-12-29')
|
||||
expect(decDayElement).toHaveClass('current-month')
|
||||
|
||||
// Days from next month should NOT have current-month class
|
||||
const janDayElement = screen.getByTestId('calendar-day-2026-01-01')
|
||||
expect(janDayElement).not.toHaveClass('current-month')
|
||||
})
|
||||
|
||||
it('highlights today when date matches current date', () => {
|
||||
const today = new Date()
|
||||
const todayString = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
const weekDates = [
|
||||
'2026-01-05',
|
||||
'2026-01-06',
|
||||
'2026-01-07',
|
||||
'2026-01-08',
|
||||
'2026-01-09',
|
||||
'2026-01-10',
|
||||
'2026-01-11',
|
||||
]
|
||||
// Only test if today happens to fall in the week we're rendering
|
||||
if (weekDates.includes(todayString)) {
|
||||
render(
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
weekDates={weekDates}
|
||||
/>,
|
||||
)
|
||||
const todayElement = screen.getByTestId(`calendar-day-${todayString}`)
|
||||
expect(todayElement).toHaveClass('today')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('selected dates', () => {
|
||||
it('marks selected dates in the week', () => {
|
||||
const selectedDates = new Set(['2026-01-06', '2026-01-07'])
|
||||
render(<CalendarWeek {...defaultProps} selectedDates={selectedDates} />)
|
||||
expect(screen.getByTestId('calendar-day-2026-01-06')).toHaveClass('selected')
|
||||
expect(screen.getByTestId('calendar-day-2026-01-07')).toHaveClass('selected')
|
||||
})
|
||||
|
||||
it('does not mark unselected dates', () => {
|
||||
const selectedDates = new Set(['2026-01-06'])
|
||||
render(<CalendarWeek {...defaultProps} selectedDates={selectedDates} />)
|
||||
expect(screen.getByTestId('calendar-day-2026-01-05')).not.toHaveClass('selected')
|
||||
})
|
||||
|
||||
it('handles empty selection set', () => {
|
||||
render(<CalendarWeek {...defaultProps} selectedDates={new Set()} />)
|
||||
defaultProps.weekDates.forEach((date) => {
|
||||
expect(screen.getByTestId(`calendar-day-${date}`)).not.toHaveClass('selected')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('planned days and bars', () => {
|
||||
it('renders bars for planned days in the week', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
expect(screen.getByTestId('calendar-event')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render bars for planned days outside the week', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-02-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
expect(screen.queryByTestId('calendar-event')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders parent name for own planned days', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
|
||||
expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders "Partner" label for partner planned days', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'other-parent',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders different day types with localized labels', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'sjukpenning',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-06',
|
||||
dayType: 'lagsta',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
expect(screen.getByText(/Sjukpenning/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Lägsta nivå/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks dubbeldag days with dubbeldag class', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: true,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const eventElement = screen.getByTestId('calendar-event')
|
||||
expect(eventElement).toHaveClass('dubbeldag')
|
||||
})
|
||||
|
||||
it('renders continuous bars spanning multiple days', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-06',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-07',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
// Should render at least one bar for the continuous period
|
||||
const events = screen.getAllByTestId('calendar-event')
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders separate bars for different day types on the same day', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'other-parent',
|
||||
date: '2026-01-05',
|
||||
dayType: 'sjukpenning',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const events = screen.getAllByTestId('calendar-event')
|
||||
// Should have separate bars for the two different (parent, dayType) combinations
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('renders separate bars for same day type but different dubbeldag flag', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: true,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const events = screen.getAllByTestId('calendar-event')
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('handles empty planned days', () => {
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={[]} />)
|
||||
expect(screen.queryByTestId('calendar-event')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event handlers', () => {
|
||||
it('calls onDayClick when clicking a day in current month', () => {
|
||||
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
|
||||
const dayElement = screen.getByTestId('calendar-day-2026-01-05')
|
||||
fireEvent.click(dayElement)
|
||||
expect(defaultProps.onDayClick).toHaveBeenCalledWith('2026-01-05')
|
||||
})
|
||||
|
||||
it('does not call onDayClick for days outside the week\'s reference month', () => {
|
||||
// When a week spans month boundaries, only days in the same month as the first date
|
||||
// will trigger handlers. 2025-12-29 is first (Dec), so 01/01+ will not trigger handlers.
|
||||
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}
|
||||
/>,
|
||||
)
|
||||
// Click a day outside the reference month (January when December is reference)
|
||||
const janDayElement = screen.getByTestId('calendar-day-2026-01-01')
|
||||
fireEvent.click(janDayElement)
|
||||
expect(onDayClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onDayClick when isCurrentMonth is false', () => {
|
||||
const onDayClick = vi.fn()
|
||||
render(
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
isCurrentMonth={false}
|
||||
onDayClick={onDayClick}
|
||||
/>,
|
||||
)
|
||||
const dayElement = screen.getByTestId('calendar-day-2026-01-05')
|
||||
fireEvent.click(dayElement)
|
||||
expect(onDayClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onPointerDown when starting drag on a current-month day', () => {
|
||||
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
|
||||
const dayElement = screen.getByTestId('calendar-day-2026-01-05')
|
||||
fireEvent.pointerDown(dayElement)
|
||||
expect(defaultProps.onPointerDown).toHaveBeenCalledWith('2026-01-05')
|
||||
})
|
||||
|
||||
it('does not call onPointerDown for days outside the week\'s reference 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 onPointerDown = vi.fn()
|
||||
render(
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
weekDates={weekDatesWithPrevMonth}
|
||||
isCurrentMonth={true}
|
||||
onPointerDown={onPointerDown}
|
||||
/>,
|
||||
)
|
||||
const janDayElement = screen.getByTestId('calendar-day-2026-01-01')
|
||||
fireEvent.pointerDown(janDayElement)
|
||||
expect(onPointerDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onPointerEnter when dragging through a current-month day', () => {
|
||||
render(<CalendarWeek {...defaultProps} isCurrentMonth={true} />)
|
||||
const dayElement = screen.getByTestId('calendar-day-2026-01-06')
|
||||
fireEvent.pointerEnter(dayElement)
|
||||
expect(defaultProps.onPointerEnter).toHaveBeenCalledWith('2026-01-06')
|
||||
})
|
||||
|
||||
it('does not call onPointerEnter for days outside the week\'s reference 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 onPointerEnter = vi.fn()
|
||||
render(
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
weekDates={weekDatesWithPrevMonth}
|
||||
isCurrentMonth={true}
|
||||
onPointerEnter={onPointerEnter}
|
||||
/>,
|
||||
)
|
||||
const janDayElement = screen.getByTestId('calendar-day-2026-01-01')
|
||||
fireEvent.pointerEnter(janDayElement)
|
||||
expect(onPointerEnter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call event handlers when isCurrentMonth is false', () => {
|
||||
const onDayClick = vi.fn()
|
||||
const onPointerDown = vi.fn()
|
||||
const onPointerEnter = vi.fn()
|
||||
render(
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
isCurrentMonth={false}
|
||||
onDayClick={onDayClick}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={onPointerEnter}
|
||||
/>,
|
||||
)
|
||||
const dayElement = screen.getByTestId('calendar-day-2026-01-05')
|
||||
fireEvent.click(dayElement)
|
||||
fireEvent.pointerDown(dayElement)
|
||||
fireEvent.pointerEnter(dayElement)
|
||||
expect(onDayClick).not.toHaveBeenCalled()
|
||||
expect(onPointerDown).not.toHaveBeenCalled()
|
||||
expect(onPointerEnter).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('handles a week with mixed planned days from both parents', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-2',
|
||||
date: '2026-01-05',
|
||||
dayType: 'sjukpenning',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-06',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: true,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
plannedDays={plannedDays}
|
||||
parentName="Alice"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByText(/Alice · Semester/)).toHaveLength(2)
|
||||
expect(screen.getByText(/Partner · Sjukpenning/)).toBeInTheDocument()
|
||||
const dubbeldagElements = screen.getAllByTestId('calendar-event').filter((el) => el.classList.contains('dubbeldag'))
|
||||
expect(dubbeldagElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('handles broken bar rendering with gaps in consecutive days', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-06',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
// Gap on 2026-01-07
|
||||
{
|
||||
id: '3',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-08',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const events = screen.getAllByTestId('calendar-event')
|
||||
// Should have at least 2 bars: one for 05-06 and one for 08
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('renders all day types with correct colors', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-06',
|
||||
dayType: 'sjukpenning',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-07',
|
||||
dayType: 'lagsta',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const events = screen.getAllByTestId('calendar-event')
|
||||
expect(events.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('filters bars to only show those spanning the current day cell', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-06',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
// Should render bars for both 2026-01-05 and 2026-01-06
|
||||
// The bars represent a continuous period, so they should be visible
|
||||
const events = screen.getAllByTestId('calendar-event')
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('month boundary edge cases', () => {
|
||||
it('correctly identifies days at month boundaries', () => {
|
||||
// Jan 31 and Feb 1 in the same 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}
|
||||
/>,
|
||||
)
|
||||
// Jan 31 (index 5) should be in current month
|
||||
const jan31 = screen.getByTestId('calendar-day-2026-01-31')
|
||||
expect(jan31).toHaveClass('current-month')
|
||||
|
||||
// Feb 1 (index 6) should NOT be in current month (isCurrentMonth only marks first week's month ref)
|
||||
const feb1 = screen.getByTestId('calendar-day-2026-02-01')
|
||||
expect(feb1).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}
|
||||
/>,
|
||||
)
|
||||
// All dates should render
|
||||
weekDates.forEach((date) => {
|
||||
expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
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}
|
||||
/>,
|
||||
)
|
||||
// All dates should render
|
||||
weekDates.forEach((date) => {
|
||||
expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoization and performance', () => {
|
||||
it('memoizes bar computation based on weekDates and plannedDays', () => {
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
const { rerender } = render(
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
plannedDays={plannedDays}
|
||||
parentName="Alice"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument()
|
||||
|
||||
// Change unrelated prop (onDayClick) - bars should remain the same
|
||||
const newOnDayClick = vi.fn()
|
||||
rerender(
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
plannedDays={plannedDays}
|
||||
onDayClick={newOnDayClick}
|
||||
parentName="Alice"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('recomputes bars when plannedDays change', () => {
|
||||
const plannedDays1: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-05',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
const plannedDays2: PlannedLeaveDay[] = [
|
||||
...plannedDays1,
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-06',
|
||||
dayType: 'sjukpenning',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
const { rerender } = render(
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
plannedDays={plannedDays1}
|
||||
parentName="Alice"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Sjukpenning/)).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
plannedDays={plannedDays2}
|
||||
parentName="Alice"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/Sjukpenning/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('§3.4/§3.6 calendarDaysByDate pass-through', () => {
|
||||
it('passes matching calendar metadata down to the CalendarDay for that date', () => {
|
||||
const calendarDaysByDate = new Map([
|
||||
[
|
||||
'2026-01-06',
|
||||
{
|
||||
date: '2026-01-06',
|
||||
name: 'Trettondedag jul',
|
||||
dayType: 'red_day' as const,
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
render(<CalendarWeek {...defaultProps} calendarDaysByDate={calendarDaysByDate} />)
|
||||
|
||||
const cell = screen.getByTestId('calendar-day-2026-01-06')
|
||||
expect(cell).toHaveAttribute('data-day-type', 'red_day')
|
||||
expect(cell).toHaveAttribute('data-holiday-name', 'Trettondedag jul')
|
||||
expect(cell).toHaveAttribute('data-is-override', 'false')
|
||||
})
|
||||
|
||||
it('passes override metadata down for an overridden date', () => {
|
||||
const calendarDaysByDate = new Map([
|
||||
[
|
||||
'2026-01-07',
|
||||
{
|
||||
date: '2026-01-07',
|
||||
name: null,
|
||||
dayType: 'working_day' as const,
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: false,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
render(<CalendarWeek {...defaultProps} calendarDaysByDate={calendarDaysByDate} />)
|
||||
|
||||
const cell = screen.getByTestId('calendar-day-2026-01-07')
|
||||
expect(cell).toHaveAttribute('data-is-override', 'true')
|
||||
expect(cell).toHaveAttribute('data-override-is-working-day', 'false')
|
||||
})
|
||||
|
||||
it('leaves days with no matching entry unaffected when calendarDaysByDate is omitted', () => {
|
||||
render(<CalendarWeek {...defaultProps} />)
|
||||
|
||||
const cell = screen.getByTestId('calendar-day-2026-01-05')
|
||||
expect(cell).toHaveAttribute('data-day-type', '')
|
||||
expect(cell).toHaveAttribute('data-is-override', 'false')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user