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) => { const translations: Record = { '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) => (
onPointerDown?.(date)} onPointerEnter={() => onPointerEnter?.(date)} > {date}
), })) vi.mock('./CalendarEvent', () => ({ CalendarEvent: ({ leaveType, isDubbeldag, parentLabel }: CalendarEventProps) => (
{parentLabel} · {leaveType}
), })) function pd(overrides: Partial & Pick): 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 & Pick): 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(), 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() expect(screen.getByText(/v\.\d+/)).toBeInTheDocument() }) it('hides week number when showWeekNumbers is false', () => { render() const weekNumberElement = screen.queryByText(/^v\.\d+$/) expect(weekNumberElement).not.toBeInTheDocument() }) it('displays correct week number for a given week', () => { render() expect(screen.getByText('v.2')).toBeInTheDocument() }) }) describe('day rendering', () => { it('renders all 7 days of the week', () => { render() defaultProps.weekDates.forEach((date) => { expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument() }) }) it('renders each day with correct date string', () => { render() defaultProps.weekDates.forEach((date) => { expect(screen.getByText(date)).toBeInTheDocument() }) }) it('marks all days as current month when they match the displayed year/month', () => { render() 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() 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() 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() 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() expect(screen.getByTestId('calendar-day-2026-01-05')).not.toHaveClass('selected') }) it('handles empty selection set', () => { render() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(1) }) it('handles empty planned days', () => { render() 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() 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() 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([ ['2026-01-10', calDay({ date: '2026-01-10', dayType: 'weekend' })], ['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })], ]) render() 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([ ['2026-01-06', calDay({ date: '2026-01-06', dayType: 'red_day', name: 'Trettondedag jul' })], ]) render() 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([ ['2026-01-06', calDay({ date: '2026-01-06', dayType: 'working_day' })], ]) render() 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() 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() 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() const bands = screen.getAllByTestId(/^calendar-band-/) expect(bands).toHaveLength(1) const ticks = Array.from(bands[0]?.querySelectorAll('.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() 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([ ['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })], ['2026-01-12', calDay({ date: '2026-01-12', dayType: 'weekend' })], ]) render() 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([ ['2026-01-03', calDay({ date: '2026-01-03', dayType: 'weekend' })], ['2026-01-04', calDay({ date: '2026-01-04', dayType: 'weekend' })], ]) render() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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( , ) 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() 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( <> , ) 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( , ) 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( , ) 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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( , ) 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() 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() 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() 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() expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument() const newOnDayClick = vi.fn() rerender() 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() expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument() expect(screen.queryByText(/Sjukpenning/)).not.toBeInTheDocument() rerender() 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() 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() 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() const cell = screen.getByTestId('calendar-day-2026-01-05') expect(cell).toHaveAttribute('data-day-type', '') expect(cell).toHaveAttribute('data-is-override', 'false') }) }) })