Files
F-r-ldraledigplannerare/frontend/src/components/common/CalendarWeek.test.tsx
T
jacobh2 f0f537c95f Hold every calendar cell to the same size
A calendar whose boxes resize is not a calendar. Each week row is its own
grid, and a bare 1fr track takes its minimum from its content, so the one
week containing "Alla helgons dag" had its seven cells squeezed while
every other week's stayed wide. The tracks are now minmax(0, 1fr) and the
cell, the day and the holiday name each refuse to contribute an intrinsic
width; the name clips to an ellipsis and keeps its full text in the title.

Row height follows the same rule: the band lanes are reserved from the
month's tallest week rather than each row's own, so a week with no leave
in it stands as tall as the week beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 14:08:04 +02:00

931 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CalendarWeek } from './CalendarWeek'
import type { BandClickInfo } from './CalendarWeek'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import type { CalendarDayProps } from './CalendarDay'
import type { CalendarEventProps } from './CalendarEvent'
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å',
'calendarDay.pinnedAria': 'pinned',
}
return translations[key] ?? key
},
}),
}))
vi.mock('./CalendarDay', () => ({
CalendarDay: ({
date,
isSelected,
isCurrentMonth,
isToday,
onClick,
onPointerDown,
onPointerEnter,
dayType,
holidayName,
isOverride,
overrideIsWorkingDay,
}: CalendarDayProps) => (
<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 }: CalendarEventProps) => (
<div data-testid="calendar-event" className={isDubbeldag ? 'dubbeldag' : ''}>
{parentLabel} · {leaveType}
</div>
),
}))
function pd(overrides: Partial<PlannedLeaveDay> & Pick<PlannedLeaveDay, 'id' | 'date'>): PlannedLeaveDay {
return {
workspaceId: 'ws-1',
childId: 'child-1',
parentPersonalDataUuid: 'parent-1',
dayType: 'vacation',
isDubbeldag: false,
wishId: null,
isPinned: false,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
...overrides,
}
}
function calDay(overrides: Partial<CalendarDayEntry> & Pick<CalendarDayEntry, 'date'>): CalendarDayEntry {
return {
name: null,
dayType: 'working_day',
isOverride: false,
overrideIsWorkingDay: null,
isFree: false,
...overrides,
}
}
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: [] as PlannedLeaveDay[],
selectedDates: new Set<string>(),
parentName: 'Test Parent',
personalDataUuid: 'parent-1',
// defaultProps.weekDates is 2026-01-05..2026-01-11, all January.
displayedYear: 2026,
displayedMonth: 0,
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} />)
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+$/)
expect(weekNumberElement).not.toBeInTheDocument()
})
it('displays correct week number for a given week', () => {
render(<CalendarWeek {...defaultProps} weekDates={defaultProps.weekDates} showWeekNumbers={true} />)
expect(screen.getByText('v.2')).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 they match the displayed year/month', () => {
render(<CalendarWeek {...defaultProps} />)
defaultProps.weekDates.forEach((date) => {
expect(screen.getByTestId(`calendar-day-${date}`)).toHaveClass('current-month')
})
})
it('marks a leading week\'s in-month days as current month and its previous-month days as not, per date — not per weekDates[0]', () => {
// Regression guard: this used to compare every day in the week
// against `weekDates[0]`'s own month, so an entire leading week
// (partly previous month) was judged by its FIRST day alone. A week
// spanning Dec 29 – Jan 4, displayed as January, must activate the
// January days (01-01..01-04) and deactivate the December ones
// (12-29..12-31) — not the other way around.
const prevMonthDates = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
render(<CalendarWeek {...defaultProps} weekDates={prevMonthDates} />)
expect(screen.getByTestId('calendar-day-2025-12-29')).not.toHaveClass('current-month')
expect(screen.getByTestId('calendar-day-2026-01-01')).toHaveClass('current-month')
})
it('highlights today when date matches current date', () => {
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']
if (weekDates.includes(todayString)) {
render(<CalendarWeek {...defaultProps} weekDates={weekDates} />)
expect(screen.getByTestId(`calendar-day-${todayString}`)).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', () => {
render(<CalendarWeek {...defaultProps} selectedDates={new Set(['2026-01-06'])} />)
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('bands: one continuous object per period', () => {
it('renders a band for a single planned day', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.getByTestId('calendar-event')).toBeInTheDocument()
})
it('does not render a band for a planned day outside the week', () => {
const plannedDays = [pd({ id: '1', date: '2026-02-05' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.queryByTestId('calendar-event')).not.toBeInTheDocument()
})
it('renders parent name for own planned days and "Partner" for the other parent', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'other-parent' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument()
expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument()
})
it('resolves a partner band label from parentNames, not the generic "Partner" string', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'partner-uuid' }),
]
const parentNames = new Map([
['parent-1', 'Test Parent'],
['partner-uuid', 'Erik'],
])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentNames={parentNames} />)
expect(screen.getByText(/Test Parent · Semester/)).toBeInTheDocument()
expect(screen.getByText(/Erik · Semester/)).toBeInTheDocument()
expect(screen.queryByText(/Partner · Semester/)).not.toBeInTheDocument()
})
it('falls back to the generic "Partner" string when the band\'s parentUuid matches no known member', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'stale-uuid' })]
const parentNames = new Map([['parent-1', 'Test Parent']])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentNames={parentNames} />)
expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument()
})
it('renders different day types with localized labels', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', dayType: 'sjukpenning' }),
pd({ id: '2', date: '2026-01-10', dayType: 'lagsta' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.getByText(/Sjukpenning/)).toBeInTheDocument()
expect(screen.getByText(/Lägsta nivå/)).toBeInTheDocument()
})
it('marks a dubbeldag day via the CalendarEvent badge', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', isDubbeldag: true })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.getByTestId('calendar-event')).toHaveClass('dubbeldag')
})
it('renders ONE band element for a run of contiguous days, not one per day', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05' }),
pd({ id: '2', date: '2026-01-06' }),
pd({ id: '3', date: '2026-01-07' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const bands = screen.getAllByTestId(/^calendar-band-/)
expect(bands).toHaveLength(1)
// The one band spans all three days as per-day funding ticks.
const ticks = bands[0]?.querySelectorAll('.calendar-band__day')
expect(ticks).toHaveLength(3)
})
it('renders separate ad-hoc bands for different (parent, dayType) combinations on the same day', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1', dayType: 'vacation' }),
pd({ id: '2', date: '2026-01-05', parentPersonalDataUuid: 'other-parent', dayType: 'sjukpenning' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(2)
})
it('renders separate ad-hoc bands for the same day type but different dubbeldag flag', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', isDubbeldag: false }),
pd({ id: '2', date: '2026-01-05', isDubbeldag: true }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(2)
})
it('closes an open band that runs through the last day of the week', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-10' }),
pd({ id: '2', date: '2026-01-11' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(1)
})
it('handles empty planned days', () => {
render(<CalendarWeek {...defaultProps} plannedDays={[]} />)
expect(screen.queryByTestId('calendar-event')).not.toBeInTheDocument()
})
it('composes the band aria-label from parent label, type label, and the interpolated date range', () => {
// Regression guard: a tips bug in this repo surfaced as the literal
// un-interpolated "Tip: {title}" in an accessibility label and nothing
// caught it. Assert the real composed string here, not merely that an
// aria-label attribute exists.
const plannedDays = [
pd({ id: '1', date: '2026-01-05', dayType: 'sjukpenning' }),
pd({ id: '2', date: '2026-01-06', dayType: 'sjukpenning' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]
expect(band).toHaveAttribute('aria-label', 'Alice · Sjukpenning, 2026-01-05 – 2026-01-06')
})
it('uses the partner label in the band aria-label for the other parent\'s days', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'other-parent' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} personalDataUuid="parent-1" />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]
expect(band).toHaveAttribute('aria-label', 'Partner · Semester, 2026-01-05 – 2026-01-05')
})
})
describe('the weekend-gap bug: a wish-linked band bridges free-day gaps', () => {
it('renders ONE band spanning a Mon-Fri + weekend + Mon-Fri wish period, not fragments', () => {
// 2026-01-05 (Mon) .. 2026-01-11 (Sun): the wish only has funded rows
// on working days (planning never persists a row for an automatically
// free weekend), but it is visually one continuous period.
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' }),
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1' }),
pd({ id: '3', date: '2026-01-07', wishId: 'wish-1' }),
pd({ id: '4', date: '2026-01-08', wishId: 'wish-1' }),
pd({ id: '5', date: '2026-01-09', wishId: 'wish-1' }),
// 01-10 (Sat) and 01-11 (Sun) have no row — they're free days — but
// the period resumes the following Monday, so the whole thing is
// still one band bridging the weekend in between.
pd({ id: '6', date: '2026-01-12', wishId: 'wish-1' }),
]
const calendarDaysByDate = new Map<string, CalendarDayEntry>([
['2026-01-10', calDay({ date: '2026-01-10', dayType: 'weekend' })],
['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })],
])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} calendarDaysByDate={calendarDaysByDate} />)
const bands = screen.getAllByTestId(/^calendar-band-/)
expect(bands).toHaveLength(1)
// This week only sees 01-05..01-11 (the band continues into next
// week): 5 funded days + 2 bridged gap days = 7 ticks this row.
expect(bands[0]?.querySelectorAll('.calendar-band__day')).toHaveLength(7)
expect(bands[0]?.querySelectorAll('.calendar-band__day--gap')).toHaveLength(2)
// Continues past this week, so no rounded right cap here.
expect(bands[0]).not.toHaveClass('calendar-band--end')
})
it('also bridges a gap that is a red day (not just a weekend)', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' }),
pd({ id: '2', date: '2026-01-07', wishId: 'wish-1' }),
]
const calendarDaysByDate = new Map<string, CalendarDayEntry>([
['2026-01-06', calDay({ date: '2026-01-06', dayType: 'red_day', name: 'Trettondedag jul' })],
])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} calendarDaysByDate={calendarDaysByDate} />)
expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(1)
})
it('does NOT bridge when the gap day is an ordinary working day (a real pause, not a free day)', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' }),
pd({ id: '2', date: '2026-01-07', wishId: 'wish-1' }),
]
const calendarDaysByDate = new Map<string, CalendarDayEntry>([
['2026-01-06', calDay({ date: '2026-01-06', dayType: 'working_day' })],
])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} calendarDaysByDate={calendarDaysByDate} />)
expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(2)
})
it('does NOT bridge a gap when calendarDaysByDate is not supplied at all (conservative fallback)', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' }),
pd({ id: '2', date: '2026-01-07', wishId: 'wish-1' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(2)
})
it('bands two different wishIds separately even if adjacent', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' }),
pd({ id: '2', date: '2026-01-06', wishId: 'wish-2' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(2)
})
it('keeps the per-day funding type visible inside a single band even when it varies day to day', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }),
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning' }),
pd({ id: '3', date: '2026-01-07', wishId: 'wish-1', dayType: 'lagsta' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const bands = screen.getAllByTestId(/^calendar-band-/)
expect(bands).toHaveLength(1)
const ticks = Array.from(bands[0]?.querySelectorAll<HTMLElement>('.calendar-band__day') ?? [])
const colors = ticks.map((tick) => tick.style.backgroundColor)
// Three distinct funding-type colors, not one flat color for the whole band.
expect(new Set(colors).size).toBe(3)
})
})
describe('band caps: rounded only at the true start/end of the whole period', () => {
it('rounds both ends when the whole band fits inside this week', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-06' }), pd({ id: '2', date: '2026-01-07' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]
expect(band).toHaveClass('calendar-band--start')
expect(band).toHaveClass('calendar-band--end')
})
it('squares off the trailing edge when the band continues into the following week', () => {
// Band runs 01-10 (Sat, in this week) through 01-13 (next week) —
// this week only sees the start, so only the left cap is rounded.
const plannedDays = [
pd({ id: '1', date: '2026-01-10', wishId: 'wish-1' }),
pd({ id: '2', date: '2026-01-13', wishId: 'wish-1' }),
]
const calendarDaysByDate = new Map<string, CalendarDayEntry>([
['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })],
['2026-01-12', calDay({ date: '2026-01-12', dayType: 'weekend' })],
])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} calendarDaysByDate={calendarDaysByDate} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]
expect(band).toHaveClass('calendar-band--start')
expect(band).not.toHaveClass('calendar-band--end')
})
it('squares off the leading edge when the band started the previous week', () => {
// 2026-01-02 is a Friday (previous week); the gap to 2026-01-05
// (Monday, this week) is exactly the weekend in between.
const plannedDays = [
pd({ id: '1', date: '2026-01-02', wishId: 'wish-1' }),
pd({ id: '2', date: '2026-01-05', wishId: 'wish-1' }),
]
const calendarDaysByDate = new Map<string, CalendarDayEntry>([
['2026-01-03', calDay({ date: '2026-01-03', dayType: 'weekend' })],
['2026-01-04', calDay({ date: '2026-01-04', dayType: 'weekend' })],
])
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} calendarDaysByDate={calendarDaysByDate} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]
expect(band).not.toHaveClass('calendar-band--start')
expect(band).toHaveClass('calendar-band--end')
})
})
describe('pinned days', () => {
it('marks a pinned day with a visible per-day marker', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', isPinned: false }),
pd({ id: '2', date: '2026-01-06', isPinned: true }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.getAllByTestId('calendar-band-day-pinned')).toHaveLength(1)
})
it('does not mark any day pinned when none are', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', isPinned: false })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(screen.queryByTestId('calendar-band-day-pinned')).not.toBeInTheDocument()
})
})
describe('band clicks', () => {
it('calls onBandClick with the wishId when a wish-linked band is clicked', () => {
const onBandClick: (info: BandClickInfo) => void = vi.fn()
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} onBandClick={onBandClick} />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(onBandClick).toHaveBeenCalledWith({
wishId: 'wish-1',
parentPersonalDataUuid: 'parent-1',
startDate: '2026-01-05',
endDate: '2026-01-05',
})
})
it('calls onBandClick with a null wishId for an ad-hoc (non-wish) band', () => {
const onBandClick: (info: BandClickInfo) => void = vi.fn()
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: null })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} onBandClick={onBandClick} />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(onBandClick).toHaveBeenCalledWith(expect.objectContaining({ wishId: null }))
})
it('is a no-op (does not throw) when no onBandClick handler is provided', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
expect(() => fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)).not.toThrow()
})
it('fires onBandClick on Enter key when focused', () => {
const onBandClick: (info: BandClickInfo) => void = vi.fn()
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} onBandClick={onBandClick} />)
fireEvent.keyDown(screen.getAllByTestId(/^calendar-band-/)[0]!, { key: 'Enter' })
expect(onBandClick).toHaveBeenCalledTimes(1)
})
})
describe('§Task7 flashWishId', () => {
it('adds the flash class to the band matching flashWishId', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId="wish-1" />)
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).toHaveClass('calendar-band--flash')
})
it('does not flash a band for a different wishId', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId="wish-2" />)
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).not.toHaveClass('calendar-band--flash')
})
it('never flashes an ad-hoc (wishId null) band even if flashWishId is set oddly', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: null })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId={null} />)
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).not.toHaveClass('calendar-band--flash')
})
})
describe('lanes: overlapping bands for two parents do not collide', () => {
it('assigns overlapping bands to different lanes (both render, neither is dropped)', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '3', date: '2026-01-05', parentPersonalDataUuid: 'other-parent' }),
pd({ id: '4', date: '2026-01-06', parentPersonalDataUuid: 'other-parent' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(2)
expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument()
expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument()
})
it('stacks overlapping bands at different bottom offsets within the same grid row, not different rows', () => {
// Regression guard for the overlay rework: lanes used to be separate
// grid rows (gridRow 2, 3, ...) below the day cells. Now every band
// shares gridRow 1 with the day cells and is stacked purely via its
// inline `bottom` offset, so overlapping bands must render at
// DIFFERENT `bottom` values while both staying in row 1.
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '3', date: '2026-01-05', parentPersonalDataUuid: 'other-parent' }),
pd({ id: '4', date: '2026-01-06', parentPersonalDataUuid: 'other-parent' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
const bands = screen.getAllByTestId(/^calendar-band-/)
expect(bands).toHaveLength(2)
bands.forEach((band) => expect(band.style.gridRow).toBe('1'))
const bottoms = bands.map((band) => band.style.bottom)
expect(new Set(bottoms).size).toBe(2)
// Both lanes share the same band height.
expect(new Set(bands.map((band) => band.style.height)).size).toBe(1)
})
})
describe('band overlay: bands render on the day-cell row, not in a gutter below it', () => {
it('places a band at gridRow 1 with a grid-column span matching its start/end columns', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-06' }),
pd({ id: '2', date: '2026-01-07' }),
pd({ id: '3', date: '2026-01-08' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
expect(band.style.gridRow).toBe('1')
// 01-06/07/08 are columns 2/3/4 (1-indexed, no week-number offset).
expect(band.style.gridColumn).toBe('2 / 5')
})
it('accounts for the week-number column offset in the band\'s grid-column span', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} showWeekNumbers={true} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
// 01-05 is column 1 of the week, shifted right by the week-number column.
expect(band.style.gridColumn).toBe('2 / 3')
})
it('reserves bottom space on the week container via --band-reserved-height when a band is present', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const week = container.querySelector('.calendar-week') as HTMLElement
expect(week.style.getPropertyValue('--band-reserved-height')).not.toBe('0px')
expect(week.style.getPropertyValue('--band-reserved-height')).toMatch(/^\d+px$/)
})
it('reserves zero space on the week container when there are no bands', () => {
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={[]} />)
const week = container.querySelector('.calendar-week') as HTMLElement
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('0px')
})
describe('monthMaxLaneCount: uniform row height across the month', () => {
it('reserves height for a week with NO bands of its own when given a month-wide max from other weeks', () => {
// Calendar.tsx computes the max lane count across every week in the
// displayed month and passes it down uniformly — a quiet week must
// still reserve as much bottom padding as the tallest week, so
// every row in the month renders the same height.
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={[]} monthMaxLaneCount={2} />)
const week = container.querySelector('.calendar-week') as HTMLElement
// bandBottomInset(4) + 2 lanes * (barHeight(20) + laneGap(2)) = 48px
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('48px')
})
it('does not shrink below what this week\'s own bands need, even if monthMaxLaneCount is smaller', () => {
// Defensive floor: monthMaxLaneCount is always computed as the max
// over all weeks including this one, so it should never actually be
// smaller than this week's own lane count — but the component must
// not let a band get squeezed if it somehow were.
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-05', parentPersonalDataUuid: 'other-parent' }),
]
const { container } = render(
<CalendarWeek {...defaultProps} plannedDays={plannedDays} monthMaxLaneCount={0} />,
)
const week = container.querySelector('.calendar-week') as HTMLElement
// This week's own 2 lanes win over the smaller monthMaxLaneCount.
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('48px')
})
it('falls back to this week\'s own lane count when monthMaxLaneCount is omitted (single-week render)', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const week = container.querySelector('.calendar-week') as HTMLElement
// bandBottomInset(4) + 1 lane * (barHeight(20) + laneGap(2)) = 26px
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('26px')
})
it('gives every week row the same --band-reserved-height when rendered together with a shared monthMaxLaneCount, even though only one has bands', () => {
// Mirrors how Calendar.tsx renders a month: several CalendarWeek
// rows side by side, all fed the same month-wide max. A week with
// no bands of its own must still match a week with two overlapping
// bands, or that row would render visibly shorter.
const busyWeekDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-05', parentPersonalDataUuid: 'other-parent' }),
]
const { container } = render(
<>
<CalendarWeek {...defaultProps} plannedDays={busyWeekDays} monthMaxLaneCount={2} />
<CalendarWeek
{...defaultProps}
weekDates={['2026-01-12', '2026-01-13', '2026-01-14', '2026-01-15', '2026-01-16', '2026-01-17', '2026-01-18']}
plannedDays={[]}
monthMaxLaneCount={2}
/>
</>,
)
const weeks = Array.from(container.querySelectorAll('.calendar-week')) as HTMLElement[]
expect(weeks).toHaveLength(2)
const heights = weeks.map((week) => week.style.getPropertyValue('--band-reserved-height'))
expect(new Set(heights).size).toBe(1)
expect(heights[0]).toBe('48px')
})
})
it('still fires the day cell\'s onClick/onPointerDown/onPointerEnter for a date under a band', () => {
// The band overlays the day cell (same grid row); the day cell must
// stay reachable for its own click/drag-select handlers underneath.
const onDayClick = vi.fn()
const onPointerDown = vi.fn()
const onPointerEnter = vi.fn()
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
render(
<CalendarWeek
{...defaultProps}
plannedDays={plannedDays}
onDayClick={onDayClick}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
/>,
)
const dayCell = screen.getByTestId('calendar-day-2026-01-05')
fireEvent.click(dayCell)
fireEvent.pointerDown(dayCell)
fireEvent.pointerEnter(dayCell)
expect(onDayClick).toHaveBeenCalledWith('2026-01-05')
expect(onPointerDown).toHaveBeenCalledWith('2026-01-05')
expect(onPointerEnter).toHaveBeenCalledWith('2026-01-05')
})
it('still fires onBandClick for a band overlaying a date, alongside the day cell staying clickable', () => {
const onBandClick: (info: BandClickInfo) => void = vi.fn()
const onDayClick = vi.fn()
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' })]
render(
<CalendarWeek
{...defaultProps}
plannedDays={plannedDays}
onBandClick={onBandClick}
onDayClick={onDayClick}
/>,
)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(onBandClick).toHaveBeenCalledWith({
wishId: 'wish-1',
parentPersonalDataUuid: 'parent-1',
startDate: '2026-01-05',
endDate: '2026-01-05',
})
// Clicking the band must not also trigger the day cell's own click.
expect(onDayClick).not.toHaveBeenCalled()
})
})
describe('event handlers', () => {
it('calls onDayClick when clicking a day in current month', () => {
render(<CalendarWeek {...defaultProps} />)
fireEvent.click(screen.getByTestId('calendar-day-2026-01-05'))
expect(defaultProps.onDayClick).toHaveBeenCalledWith('2026-01-05')
})
it('calls onDayClick for a leading week\'s day that belongs to the displayed month (crossing the month boundary)', () => {
// Regression guard for the drag-select gating bug: a leading week's
// in-month days must be clickable even though `weekDates[0]` belongs
// to the previous month.
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onDayClick = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onDayClick={onDayClick} />)
fireEvent.click(screen.getByTestId('calendar-day-2026-01-01'))
expect(onDayClick).toHaveBeenCalledWith('2026-01-01')
})
it('still calls onDayClick for a day belonging to the adjacent (non-displayed) month in the same leading week', () => {
// Regression guard for the drag-across-month-boundary bug: gating
// click/pointer handlers on `dayIsCurrentMonth` per-cell killed every
// adjacent-month cell's interactivity, which broke a drag that
// crosses a month boundary (see Calendar.tsx's pointerup handler /
// drag-create-delete.spec.ts). `isCurrentMonth` still only drives the
// dimmed `current-month`/disabled styling; clicking a visible
// adjacent-month day is a deliberate, supported way to plan that day.
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onDayClick = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onDayClick={onDayClick} />)
fireEvent.click(screen.getByTestId('calendar-day-2025-12-29'))
expect(onDayClick).toHaveBeenCalledWith('2025-12-29')
})
it('still calls onDayClick when no day in the week matches the displayed year/month', () => {
const onDayClick = vi.fn()
render(<CalendarWeek {...defaultProps} displayedMonth={5} onDayClick={onDayClick} />)
fireEvent.click(screen.getByTestId('calendar-day-2026-01-05'))
expect(onDayClick).toHaveBeenCalledWith('2026-01-05')
})
it('calls onPointerDown when starting drag on a current-month day', () => {
render(<CalendarWeek {...defaultProps} />)
fireEvent.pointerDown(screen.getByTestId('calendar-day-2026-01-05'))
expect(defaultProps.onPointerDown).toHaveBeenCalledWith('2026-01-05')
})
it('calls onPointerDown for a leading week\'s day that belongs to the displayed month (crossing the month boundary)', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerDown = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerDown={onPointerDown} />)
fireEvent.pointerDown(screen.getByTestId('calendar-day-2026-01-01'))
expect(onPointerDown).toHaveBeenCalledWith('2026-01-01')
})
it('still calls onPointerDown for a day belonging to the adjacent (non-displayed) month in the same leading week', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerDown = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerDown={onPointerDown} />)
fireEvent.pointerDown(screen.getByTestId('calendar-day-2025-12-29'))
expect(onPointerDown).toHaveBeenCalledWith('2025-12-29')
})
it('calls onPointerEnter when dragging through a current-month day', () => {
render(<CalendarWeek {...defaultProps} />)
fireEvent.pointerEnter(screen.getByTestId('calendar-day-2026-01-06'))
expect(defaultProps.onPointerEnter).toHaveBeenCalledWith('2026-01-06')
})
it('calls onPointerEnter for a leading week\'s day that belongs to the displayed month (crossing the month boundary)', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerEnter = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerEnter={onPointerEnter} />)
fireEvent.pointerEnter(screen.getByTestId('calendar-day-2026-01-01'))
expect(onPointerEnter).toHaveBeenCalledWith('2026-01-01')
})
it('still calls onPointerEnter for a day belonging to the adjacent (non-displayed) month in the same leading week', () => {
const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']
const onPointerEnter = vi.fn()
render(<CalendarWeek {...defaultProps} weekDates={weekDatesWithPrevMonth} onPointerEnter={onPointerEnter} />)
fireEvent.pointerEnter(screen.getByTestId('calendar-day-2025-12-29'))
expect(onPointerEnter).toHaveBeenCalledWith('2025-12-29')
})
it('still calls event handlers when no day in the week matches the displayed year/month, so a drag can cross a month boundary', () => {
// A drag whose anchor is the last day of one month and whose current
// cell is the first day of the next crosses a week row where NONE of
// the days match the still-displayed month — that cell must still be
// reachable, or the drag can never enter the new month at all.
const onDayClick = vi.fn()
const onPointerDown = vi.fn()
const onPointerEnter = vi.fn()
render(
<CalendarWeek
{...defaultProps}
displayedMonth={5}
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).toHaveBeenCalledWith('2026-01-05')
expect(onPointerDown).toHaveBeenCalledWith('2026-01-05')
expect(onPointerEnter).toHaveBeenCalledWith('2026-01-05')
})
})
describe('month boundary edge cases', () => {
it('correctly identifies days at month boundaries', () => {
const weekDates = ['2026-01-26', '2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31', '2026-02-01']
render(<CalendarWeek {...defaultProps} weekDates={weekDates} />)
expect(screen.getByTestId('calendar-day-2026-01-31')).toHaveClass('current-month')
expect(screen.getByTestId('calendar-day-2026-02-01')).not.toHaveClass('current-month')
})
it('handles start of month week', () => {
const weekDates = ['2026-01-26', '2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31', '2026-02-01']
render(<CalendarWeek {...defaultProps} weekDates={weekDates} />)
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} />)
weekDates.forEach((date) => {
expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument()
})
})
})
describe('memoization and performance', () => {
it('memoizes band computation based on weekDates and plannedDays', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
const { rerender } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument()
const newOnDayClick = vi.fn()
rerender(<CalendarWeek {...defaultProps} plannedDays={plannedDays} onDayClick={newOnDayClick} parentName="Alice" />)
expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument()
})
it('recomputes bands when plannedDays change', () => {
const plannedDays1 = [pd({ id: '1', date: '2026-01-05' })]
const plannedDays2 = [...plannedDays1, pd({ id: '2', date: '2026-01-06', dayType: 'sjukpenning' })]
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')
})
})
})