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:
2026-09-14 02:33:03 +02:00
parent 3ca1418d94
commit 76b1e712b9
218 changed files with 29867 additions and 6085 deletions
@@ -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')
})
})
})
+63 -2
View File
@@ -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 (