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:
@@ -85,7 +85,7 @@ describe('CalendarDay', () => {
|
||||
/>
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveAttribute('aria-label', '2026-06-15 with 2 event(s)')
|
||||
expect(day).toHaveAttribute('aria-label', '2026-06-15 med 2 händelse(r)')
|
||||
})
|
||||
|
||||
it('renders without events by default', () => {
|
||||
@@ -109,4 +109,79 @@ describe('CalendarDay', () => {
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).not.toHaveClass('calendar-day--today')
|
||||
})
|
||||
|
||||
describe('§3.4 red day / klämdag shading', () => {
|
||||
it('applies the red-day class and shows a cleaned-up holiday name', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay date="2026-06-15" dayType="red_day" holidayName="Påskdagen; Söndag" />
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--red-day')
|
||||
// the "; Söndag" weekday suffix is stripped, not dumped raw
|
||||
expect(day).toHaveAttribute('title', 'Påskdagen')
|
||||
expect(day.getAttribute('aria-label')).toContain('Påskdagen')
|
||||
expect(day.getAttribute('aria-label')).not.toContain('Söndag')
|
||||
expect(getByTestId('calendar-day-type-badge')).toHaveTextContent('R')
|
||||
})
|
||||
|
||||
it('applies the klämdag class and a non-color aria signal', () => {
|
||||
const { getByTestId } = render(<CalendarDay date="2026-06-15" dayType="klamdag" />)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--klamdag')
|
||||
expect(day.getAttribute('aria-label')).toContain('klämdag')
|
||||
expect(getByTestId('calendar-day-type-badge')).toHaveTextContent('K')
|
||||
})
|
||||
|
||||
it('does not shade a plain working day', () => {
|
||||
const { getByTestId } = render(<CalendarDay date="2026-06-15" dayType="working_day" />)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).not.toHaveClass('calendar-day--red-day')
|
||||
expect(day).not.toHaveClass('calendar-day--klamdag')
|
||||
})
|
||||
|
||||
it('keeps the original aria-label formula when no calendar metadata is supplied (backward compatible)', () => {
|
||||
const { getByTestId } = render(<CalendarDay date="2026-06-15" />)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveAttribute('aria-label', '2026-06-15')
|
||||
expect(day).not.toHaveAttribute('title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('§3.6 workspace day overrides', () => {
|
||||
it('marks an override with a distinct class and badge, working-day variant', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay date="2026-06-15" isOverride={true} overrideIsWorkingDay={true} />
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--override')
|
||||
expect(day.getAttribute('aria-label')).toContain('arbetsdag')
|
||||
expect(getByTestId('calendar-day-override-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks an override with a distinct class and badge, free-day variant', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay date="2026-06-15" isOverride={true} overrideIsWorkingDay={false} />
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--override')
|
||||
expect(day.getAttribute('aria-label')).toContain('ledig dag')
|
||||
})
|
||||
|
||||
it('combines a statutory red day with an override distinctly', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay
|
||||
date="2026-06-15"
|
||||
dayType="red_day"
|
||||
holidayName="Midsommardagen"
|
||||
isOverride={true}
|
||||
overrideIsWorkingDay={true}
|
||||
/>
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--red-day')
|
||||
expect(day).toHaveClass('calendar-day--override')
|
||||
expect(day.getAttribute('aria-label')).toContain('Midsommardagen')
|
||||
expect(day.getAttribute('aria-label')).toContain('arbetsdag')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { formatHolidayName } from '@/utils/dates'
|
||||
import type { CalendarDayType } from '@/types'
|
||||
|
||||
export interface CalendarDayProps {
|
||||
date: string
|
||||
@@ -10,6 +13,14 @@ export interface CalendarDayProps {
|
||||
onPointerDown?: ((date: string) => void) | undefined
|
||||
onPointerEnter?: ((date: string) => void) | undefined
|
||||
onActivate?: (() => void) | undefined
|
||||
/** §3.4 — statutory calendar classification for this date, from `GET /api/calendar/days`. */
|
||||
dayType?: CalendarDayType | undefined
|
||||
/** Exact holiday name (possibly the combined "X; Söndag" form); only meaningful when `dayType === 'red_day'`. */
|
||||
holidayName?: string | null | undefined
|
||||
/** §3.6 — true iff this date has a workspace day override. */
|
||||
isOverride?: boolean | undefined
|
||||
/** The override's value when `isOverride` is true (true = marked as a working day). */
|
||||
overrideIsWorkingDay?: boolean | null | undefined
|
||||
}
|
||||
|
||||
export function CalendarDay({
|
||||
@@ -22,7 +33,12 @@ export function CalendarDay({
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
onActivate,
|
||||
dayType,
|
||||
holidayName,
|
||||
isOverride = false,
|
||||
overrideIsWorkingDay = null,
|
||||
}: CalendarDayProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const dayNum = new Date(date + 'T00:00:00Z').getUTCDate()
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
@@ -35,19 +51,64 @@ export function CalendarDay({
|
||||
}
|
||||
}
|
||||
|
||||
const cleanHolidayName = holidayName ? formatHolidayName(holidayName) : null
|
||||
|
||||
// Non-color signals describing the day's calendar status, appended to the
|
||||
// existing aria-label formula so unrelated call sites (no dayType/override
|
||||
// props supplied) keep their exact previous aria-label.
|
||||
const descriptors: string[] = []
|
||||
if (cleanHolidayName) {
|
||||
descriptors.push(cleanHolidayName)
|
||||
} else if (dayType === 'klamdag') {
|
||||
descriptors.push(t('calendarDay.klamdagAria'))
|
||||
}
|
||||
if (isOverride) {
|
||||
descriptors.push(
|
||||
overrideIsWorkingDay ? t('calendarDay.overrideWorkingAria') : t('calendarDay.overrideFreeAria'),
|
||||
)
|
||||
}
|
||||
|
||||
const baseLabel = events.length > 0 ? t('calendarDay.ariaWithEvents', { date, count: events.length }) : date
|
||||
const ariaLabel = descriptors.length > 0 ? `${baseLabel}, ${descriptors.join(', ')}` : baseLabel
|
||||
const title = descriptors.length > 0 ? descriptors.join(' · ') : undefined
|
||||
|
||||
const className = [
|
||||
'calendar-day',
|
||||
!isCurrentMonth ? 'calendar-day--disabled' : '',
|
||||
events.length > 0 ? 'calendar-day--has-events' : '',
|
||||
isToday ? 'calendar-day--today' : '',
|
||||
isSelected ? 'calendar-day--selected' : '',
|
||||
dayType === 'red_day' ? 'calendar-day--red-day' : '',
|
||||
dayType === 'klamdag' ? 'calendar-day--klamdag' : '',
|
||||
isOverride ? 'calendar-day--override' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="calendar-day"
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${date}${events.length > 0 ? ` with ${events.length} event(s)` : ''}`}
|
||||
className={`calendar-day ${!isCurrentMonth ? 'calendar-day--disabled' : ''} ${events.length > 0 ? 'calendar-day--has-events' : ''} ${isToday ? 'calendar-day--today' : ''} ${isSelected ? 'calendar-day--selected' : ''}`}
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
className={className}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={() => onPointerDown?.(date)}
|
||||
onPointerEnter={() => onPointerEnter?.(date)}
|
||||
>
|
||||
<div className="calendar-day__number">{dayNum}</div>
|
||||
{(dayType === 'red_day' || dayType === 'klamdag') && (
|
||||
<span className="calendar-day__type-badge" data-testid="calendar-day-type-badge" aria-hidden="true">
|
||||
{dayType === 'red_day' ? 'R' : 'K'}
|
||||
</span>
|
||||
)}
|
||||
{isOverride && (
|
||||
<span className="calendar-day__override-badge" data-testid="calendar-day-override-badge" aria-hidden="true">
|
||||
{overrideIsWorkingDay ? '⚒' : '✓'}
|
||||
</span>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<div className="calendar-day__events" data-testid="calendar-day-events">
|
||||
{events.map((event, idx) => (
|
||||
|
||||
@@ -70,6 +70,6 @@ describe('CalendarEvent', () => {
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={() => {}} />
|
||||
)
|
||||
const button = getByTestId('dismiss-button')
|
||||
expect(button).toHaveAttribute('aria-label', 'Dismiss vacation')
|
||||
expect(button).toHaveAttribute('aria-label', 'Avfärda vacation')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
|
||||
export interface CalendarEventProps {
|
||||
leaveType: string
|
||||
@@ -17,6 +18,7 @@ export function CalendarEvent({
|
||||
isDubbeldag = false,
|
||||
parentLabel,
|
||||
}: CalendarEventProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const displayLabel = parentLabel ? `${parentLabel} · ${leaveType}` : leaveType
|
||||
|
||||
return (
|
||||
@@ -36,7 +38,7 @@ export function CalendarEvent({
|
||||
e.stopPropagation()
|
||||
onDismiss()
|
||||
}}
|
||||
aria-label={`Dismiss ${leaveType}`}
|
||||
aria-label={t('calendarEvent.dismissAria', { leaveType })}
|
||||
data-testid="dismiss-button"
|
||||
>
|
||||
×
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ReactNode, useMemo } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
|
||||
import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
|
||||
import { getISOWeekNumber } from '@/utils/dates'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { CalendarDay } from './CalendarDay'
|
||||
import { CalendarEvent } from './CalendarEvent'
|
||||
|
||||
@@ -16,6 +17,8 @@ export interface CalendarWeekProps {
|
||||
onDayClick: (date: string) => void
|
||||
onPointerDown: (date: string) => void
|
||||
onPointerEnter: (date: string) => void
|
||||
/** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */
|
||||
calendarDaysByDate?: Map<string, CalendarDayEntry> | undefined
|
||||
}
|
||||
|
||||
interface BarData {
|
||||
@@ -164,7 +167,9 @@ export function CalendarWeek({
|
||||
onDayClick,
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
calendarDaysByDate,
|
||||
}: CalendarWeekProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const bars = useMemo(() => buildBarsForWeek(weekDates, plannedDays), [weekDates, plannedDays])
|
||||
|
||||
const maxLane = bars.length > 0 ? Math.max(...bars.map((b) => b.lane)) : 0
|
||||
@@ -193,6 +198,7 @@ export function CalendarWeek({
|
||||
const weekDateObj = new Date(weekDates[0] + 'T00:00:00Z')
|
||||
const dateObj = new Date(date + 'T00:00:00Z')
|
||||
const dayIsCurrentMonth = dateObj.getMonth() === weekDateObj.getMonth()
|
||||
const calendarInfo = calendarDaysByDate?.get(date)
|
||||
|
||||
return (
|
||||
<div key={date} className="calendar-week__day-cell" style={{ position: 'relative' }}>
|
||||
@@ -201,6 +207,10 @@ export function CalendarWeek({
|
||||
isCurrentMonth={isCurrentMonth && dayIsCurrentMonth}
|
||||
isToday={date === getTodayString()}
|
||||
isSelected={selectedDates.has(date)}
|
||||
dayType={calendarInfo?.dayType}
|
||||
holidayName={calendarInfo?.name ?? null}
|
||||
isOverride={calendarInfo?.isOverride ?? false}
|
||||
overrideIsWorkingDay={calendarInfo?.overrideIsWorkingDay ?? null}
|
||||
onClick={() => {
|
||||
if (isCurrentMonth && dayIsCurrentMonth) {
|
||||
onDayClick(date)
|
||||
@@ -236,8 +246,8 @@ export function CalendarWeek({
|
||||
})
|
||||
.map((bar, idx) => {
|
||||
const parentUuid = bar.parentUuid
|
||||
const label = parentUuid === personalDataUuid ? parentName : 'Partner'
|
||||
const typeLabel = getLabelForType(bar.dayType)
|
||||
const label = parentUuid === personalDataUuid ? parentName : t('calendar.partner')
|
||||
const typeLabel = getLabelForType(bar.dayType, t)
|
||||
const color = getColorForType(bar.dayType)
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user