Files
F-r-ldraledigplannerare/frontend/src/pages/Calendar.test.tsx
T
jacobh2 c2e64b504d Author leave on the calendar itself
Planning used to mean filling in a wish list on one tab, pressing a button to
see a suggestion, then pressing another to accept it — with nothing on screen
telling you whether a plan was active. Dragging a range on the calendar now
creates the period directly, clicking a band opens it for editing, and each
change is recomputed and saved in one step. A period repeats never or yearly,
matching what the planner can actually express.

Clicking a day edits that day: its funding type, which pins it so the planner
leaves it alone, and whether the day counts as worked or off. That switch used
to be a mode covering the whole grid, which is what made the calendar confusing.

The season presets move to a rail beside the grid. Picking one lets the planner
choose the placement, then jumps there and flashes it, rather than asking the
user to guess dates the engine already knows.

A sync that funds nothing now says so, naming the period and how many days it
fell short, instead of leaving the screen unchanged and looking broken.
2026-09-17 21:31:54 +02:00

1500 lines
64 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Calendar } from './Calendar'
import { useWorkspace } from '@/context'
import {
usePlannedDays,
useCalendarDays,
useDayOverrides,
useShowWeekNumbers,
getLastLeaveType,
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
} from '@/hooks'
import { LanguageProvider } from '@/i18n'
import type { PlannedDayModalProps } from '@/components/modals/PlannedDayModal'
import type { PeriodPopoverProps, PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover'
import type { PresetRailProps, SeasonPreset } from '@/components/calendar/PresetRail'
import {
makeWorkspaceContextValue,
makePlannedDaysResult,
makeCalendarDaysResult,
makeDayOverridesResult,
makeShowWeekNumbersTuple,
makePlannedLeaveDay,
makeLeaveWishesResult,
makeWorkspaceMembersResult,
makePlanSyncResult,
makeLeaveWish,
makePersonalData,
makePlanSyncResponse,
makePlanChange,
makeWindowOutcome,
makeRejection,
makePlanProposal,
makePlanApplyResponse,
} from '../../tests/fixtures'
import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar'
vi.mock('@/context', () => ({
useWorkspace: vi.fn(),
}))
vi.mock('@/hooks', () => ({
usePlannedDays: vi.fn(),
useShowWeekNumbers: vi.fn(() => [false]),
getLastLeaveType: vi.fn(() => 'vacation'),
setLastLeaveType: vi.fn(),
useCalendarDays: vi.fn(),
useDayOverrides: vi.fn(),
useLeaveWishes: vi.fn(),
useWorkspaceMembers: vi.fn(),
usePlanSync: vi.fn(),
}))
vi.mock('@/components/layout/Layout', () => ({
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/common/LoadingSpinner', () => ({
LoadingSpinner: () => <div>Loading...</div>,
}))
vi.mock('@/components/modals/PlannedDayModal', () => ({
// Exposes the props this test suite cares about as data attributes/buttons
// so tests can assert on Calendar.tsx's wiring without rendering the real
// modal (that component has its own exhaustive test suite).
PlannedDayModal: (props: PlannedDayModalProps) => (
<div
data-testid="modal"
data-default-day-type={props.defaultDayType ?? ''}
data-existing-day={props.existingDay ? 'yes' : 'no'}
data-parents-count={props.parents.length}
data-day-info={props.dayInfo ? 'yes' : 'no'}
data-override-error={props.overrideError ?? ''}
>
<button
data-testid="modal-save"
onClick={() => void props.onSave({ dayType: 'vacation', parentId: props.selectedParentId, isDubbeldag: false })}
>
save
</button>
{props.onDelete && (
<button data-testid="modal-delete" onClick={() => void props.onDelete?.()}>
delete
</button>
)}
{props.onToggleOverride && (
<button data-testid="modal-toggle-override" onClick={() => void props.onToggleOverride?.()}>
toggle
</button>
)}
<button data-testid="modal-close" onClick={props.onClose}>
close
</button>
</div>
),
}))
vi.mock('@/components/calendar/PeriodPopover', () => ({
PeriodPopover: (props: PeriodPopoverProps) => (
<div
data-testid="period-popover"
data-wish-id={props.wish?.id ?? 'null'}
data-start={props.startDate}
data-end={props.endDate}
data-error={props.error ?? ''}
>
<button
data-testid="period-save"
onClick={() =>
void props.onSave({
name: 'Edited name',
startDate: props.startDate,
endDate: props.endDate,
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'yearly',
lastYear: null,
} satisfies PeriodPopoverSavePayload)
}
>
save
</button>
<button data-testid="period-delete" onClick={() => void props.onDelete()}>
delete
</button>
<button data-testid="period-close" onClick={props.onClose}>
close
</button>
</div>
),
}))
const SOMMAR_PRESET: SeasonPreset = {
key: 'season-sommar',
labelKey: 'wishEditor.seasonSommar',
startMonth: 6,
startDay: 1,
endMonth: 8,
endDay: 31,
defaultWeeks: 6,
}
vi.mock('@/components/calendar/PresetRail', () => ({
PresetRail: (props: PresetRailProps) => (
<div data-testid="preset-rail-mock">
<button data-testid="preset-click" disabled={props.isBusy} onClick={() => void props.onSelectPreset(SOMMAR_PRESET)}>
preset
</button>
</div>
),
}))
describe('Calendar', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const mockWorkspace = makeWorkspaceContextValue({
selectedChildId: 'child-1',
personalDataUuid: 'person-1',
})
const mockPlannedDays = makePlannedDaysResult()
const mockCalendarWindow = makeCalendarDaysResult()
const mockDayOverrides = makeDayOverridesResult()
const mockLeaveWishes = makeLeaveWishesResult()
const mockMembers = makeWorkspaceMembersResult()
const mockSync = makePlanSyncResult()
beforeEach(() => {
vi.mocked(useCalendarDays).mockReturnValue(mockCalendarWindow)
vi.mocked(useDayOverrides).mockReturnValue(mockDayOverrides)
vi.mocked(useLeaveWishes).mockReturnValue(mockLeaveWishes)
vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers)
vi.mocked(usePlanSync).mockReturnValue(mockSync)
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
})
function todayDateString(): string {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
it('renders loading state while data is loading', () => {
vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspace, isLoading: true })
render(<Calendar />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('renders 7 weekday headers in Monday-first Swedish order', () => {
render(<Calendar />)
const weekdays = screen.getAllByText(/^(Mån|Tis|Ons|Tor|Fre|Lör|Sön)$/)
expect(weekdays).toHaveLength(7)
const weekdayOrder = ['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön']
weekdays.forEach((el, idx) => {
expect(el.textContent).toBe(weekdayOrder[idx])
})
})
it('renders day cells for all weeks in a month', () => {
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
expect(calendarDays.length).toBeGreaterThan(28)
expect(calendarDays.length % 7).toBe(0)
})
it('opens the day popover when clicking a current-month day', () => {
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
if (currentMonthDay) {
fireEvent.click(currentMonthDay)
expect(screen.getByTestId('modal')).toBeInTheDocument()
}
})
it('does not open the day popover when clicking disabled (non-current-month) day', () => {
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const disabledDay = calendarDays.find((el) => el.classList.contains('calendar-day--disabled'))
if (disabledDay) {
fireEvent.click(disabledDay)
expect(disabledDay).toHaveClass('calendar-day--disabled')
}
})
it('displays current month title', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
expect(title.textContent).toMatch(/\d{4}/)
})
it('handles next/prev month navigation', () => {
render(<Calendar />)
fireEvent.click(screen.getByLabelText('Nästa månad'))
fireEvent.click(screen.getByLabelText('Föregående månad'))
expect(screen.getByTestId('calendar')).toBeInTheDocument()
})
it('highlights date range live while dragging, before pointerup', () => {
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
expect(currentMonthDays.length).toBeGreaterThanOrEqual(2)
const startDay = currentMonthDays[0] as HTMLElement
const endDay = currentMonthDays[3] as HTMLElement
fireEvent.pointerDown(startDay)
fireEvent.pointerEnter(endDay)
const selectedDaysInRange = Array.from(calendarDays).filter((el) => el.classList.contains('calendar-day--selected'))
expect(selectedDaysInRange.length).toBeGreaterThan(0)
})
it('jumps forward/backward via month and year dropdowns', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
fireEvent.change(monthSelect, { target: { value: '5' } })
expect(monthSelect.value).toBe('5')
fireEvent.change(yearSelect, { target: { value: '2035' } })
expect(yearSelect.value).toBe('2035')
})
it('year dropdown is derived from the calendar window\'s minYear/maxYear', () => {
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({ data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }) }),
)
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(Math.min(...options)).toBe(2026)
expect(Math.max(...options)).toBe(2055)
})
it('always includes the current year even before the calendar window has loaded', () => {
vi.mocked(useCalendarDays).mockReturnValue(makeCalendarDaysResult({ data: null, isLoading: true }))
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(options).toContain(new Date().getFullYear())
})
describe('§3.4 red day / klämdag shading', () => {
it('shades a statutory red day and shows its name', () => {
const today = todayDateString()
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({
days: [makeCalendarDayEntry({ date: today, name: 'Påskdagen; Söndag', dayType: 'red_day', isFree: true })],
}),
}),
)
render(<Calendar />)
const redDay = document.querySelector('.calendar-day--red-day')
expect(redDay).toBeInTheDocument()
expect(redDay).toHaveAttribute('title', 'Påskdagen')
})
})
describe('§Task7 day popover replaces the override toolbar', () => {
it('does not render an override-mode toggle in the toolbar', () => {
render(<Calendar />)
expect(screen.queryByTestId('calendar-override-toggle')).not.toBeInTheDocument()
expect(screen.queryByTestId('calendar-override-hint')).not.toBeInTheDocument()
})
it('passes the clicked date\'s calendar metadata as dayInfo to the day popover', () => {
const today = todayDateString()
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, isFree: true })] }),
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
expect(screen.getByTestId('modal')).toHaveAttribute('data-day-info', 'yes')
})
it('toggling the day popover\'s override switch marks a free day as a working day', async () => {
const today = todayDateString()
const upsert = vi.fn().mockResolvedValue(undefined)
const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined)
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }),
refetch: refetchCalendarWindow,
}),
)
vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() =>
expect(upsert).toHaveBeenCalledWith({ workspaceId: 'ws-1', date: today, isWorkingDay: true, note: null }),
)
await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalled())
})
it('removes an existing override when toggled again', async () => {
const today = todayDateString()
const removeOverride = vi.fn().mockResolvedValue(undefined)
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({
days: [makeCalendarDayEntry({ date: today, isOverride: true, overrideIsWorkingDay: false, isFree: true })],
}),
}),
)
vi.mocked(useDayOverrides).mockReturnValue(
makeDayOverridesResult({
data: [{ id: 'ov-1', workspaceId: 'ws-1', date: today, isWorkingDay: false, note: null, createdAt: null, updatedAt: null }],
upsert: vi.fn(),
remove: removeOverride,
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() => expect(removeOverride).toHaveBeenCalledWith('ov-1'))
})
it('surfaces an override failure through the modal\'s overrideError prop', async () => {
const today = todayDateString()
const upsert = vi.fn().mockRejectedValue(new Error('quota exceeded'))
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }),
}),
)
vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() => expect(screen.getByTestId('modal')).toHaveAttribute('data-override-error', 'quota exceeded'))
})
})
describe('§Task7 saving a day pins it', () => {
it('pins a brand-new day created through the day popover', async () => {
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
fireEvent.click(screen.getByTestId('modal-save'))
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true })))
})
it('pins an existing day edited through the day popover', async () => {
const today = todayDateString()
const update = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })],
update,
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-save'))
await waitFor(() =>
expect(update).toHaveBeenCalledWith('pd-1', expect.objectContaining({ is_pinned: true })),
)
})
it('shows a generic error when saving a day rejects with a non-Error value', async () => {
const add = vi.fn().mockRejectedValue('boom')
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
fireEvent.click(screen.getByTestId('modal-save'))
// The mocked modal doesn't surface `error`, but the save must not
// crash and must leave the modal open with the day still selected.
await waitFor(() => expect(add).toHaveBeenCalled())
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
it('shows a generic error when deleting a day rejects with a non-Error value', async () => {
const today = todayDateString()
const remove = vi.fn().mockRejectedValue('boom')
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })],
remove,
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-delete'))
await waitFor(() => expect(remove).toHaveBeenCalled())
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
})
describe('§Task7 fixing the hardcoded single parent', () => {
it('feeds every workspace member into the day popover\'s parents list', () => {
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'pd-a', name: 'Alex' }), makePersonalData({ uuid: 'pd-b', name: 'Sam' })],
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
expect(screen.getByTestId('modal')).toHaveAttribute('data-parents-count', '2')
})
it('falls back to the logged-in parent alone while members have not loaded', () => {
vi.mocked(useWorkspaceMembers).mockReturnValue(makeWorkspaceMembersResult({ data: [] }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
expect(screen.getByTestId('modal')).toHaveAttribute('data-parents-count', '1')
})
})
describe('§Task7 drag-select creates a period', () => {
it('creates a fixed, once wish covering the dragged range and syncs', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
const startDay = currentMonthDays[0] as HTMLElement
const endDay = currentMonthDays[3] as HTMLElement
fireEvent.pointerDown(startDay)
fireEvent.pointerEnter(endDay)
fireEvent.pointerUp(window)
await waitFor(() =>
expect(add).toHaveBeenCalledWith(
expect.objectContaining({
recurrence: 'once',
windowMode: 'fixed',
parentMode: 'specific',
parentPersonalDataUuid: 'person-1',
}),
),
)
// No day popover opens for a multi-day drag — the period is created directly.
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
it('does not open the day popover for a single-day drag (falls back to the normal click path)', () => {
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
fireEvent.pointerDown(currentMonthDay)
fireEvent.pointerUp(window)
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
it('shows an error toast when creating the period fails', async () => {
const add = vi.fn().mockRejectedValue(new Error('validation failed'))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('validation failed'))
})
})
describe('§Task7 band click opens the period popover', () => {
it('opens the period popover with the resolved wish when a wish-linked band is clicked', () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
fireEvent.click(band)
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-wish-id', 'lw-1')
})
it('opens the period popover with a null wish for an ad-hoc band', () => {
const today = todayDateString()
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
fireEvent.click(band)
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-wish-id', 'null')
})
it('saving a period edit updates the wish and re-syncs', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const update = vi.fn().mockResolvedValue(wish)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-save'))
await waitFor(() => expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ name: 'Edited name' })))
await waitFor(() => expect(sync).toHaveBeenCalled())
expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
})
it('deleting a wish-linked period removes the wish and re-syncs', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const remove = vi.fn().mockResolvedValue(undefined)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
await waitFor(() => expect(sync).toHaveBeenCalled())
})
it('undo after deleting a wish-linked period recreates it and re-syncs', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1', name: 'Sommar' })
const remove = vi.fn().mockResolvedValue(undefined)
const add = vi.fn().mockResolvedValue(wish)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, add }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete'))
const undoButton = await screen.findByTestId('calendar-toast-undo')
fireEvent.click(undoButton)
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ name: 'Sommar' })))
})
it('deleting a wish-linked period no longer present locally shows a toast with no undo', async () => {
const today = todayDateString()
const remove = vi.fn().mockResolvedValue(undefined)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({ changes: [makePlanChange({ oldWishId: 'lw-1', newWishId: null, kind: 'removed' })] }),
)
// The wish is gone from the local list by the time delete runs (e.g. a
// concurrent refresh) — there is nothing to offer as an undo, even
// though the sync still reports (and toasts) a real change.
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [], remove }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toBeInTheDocument())
expect(screen.queryByTestId('calendar-toast-undo')).not.toBeInTheDocument()
})
it('deleting an ad-hoc period removes the underlying planned days directly (no sync)', async () => {
const today = todayDateString()
const remove = vi.fn().mockResolvedValue(undefined)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
remove,
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1'))
// Sync was still called once, from the initial auto-sync on mount —
// but not a second time for this ad-hoc delete (it isn't wish-driven).
expect(sync).toHaveBeenCalledTimes(1)
})
it('closes the popover on close/cancel', () => {
const today = todayDateString()
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(screen.getByTestId('period-popover')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('period-close'))
expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
})
})
describe('§Task7 preset rail', () => {
it('creates the selected preset as a flexible, yearly wish', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() =>
expect(add).toHaveBeenCalledWith(expect.objectContaining({ windowMode: 'flexible', recurrence: 'yearly' })),
)
await waitFor(() => expect(sync).toHaveBeenCalled())
})
it('navigates the month view to the resolved placement and flashes the band', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [makePlanChange({ newWishId: 'lw-preset', date: '2030-07-10' })],
}),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => {
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
expect(yearSelect.value).toBe('2030')
})
})
it('shows an error toast when creating the preset fails', async () => {
const add = vi.fn().mockRejectedValue(new Error('no priority left'))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('no priority left'))
})
})
describe('§Task7 sync toast + undo', () => {
it('shows a toast summarizing what changed after a sync with changes', async () => {
const created = makeLeaveWish({ id: 'lw-new', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' }), makePlanChange({ newWishId: 'lw-new' })] }),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Sommar'))
expect(screen.getByTestId('calendar-toast')).toHaveTextContent('2')
})
it('shows no toast when the sync produces no changes', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
await waitFor(() => expect(add).toHaveBeenCalled())
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
})
it('explains a fully-starved sync (no changes, nothing funded) instead of showing nothing', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [],
proposal: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-new',
startDate: '2026-06-01',
endDate: '2026-06-14',
weeksWished: 2,
weeksFunded: 0,
shortfallDays: 10,
data: { wish_name: 'Sommar', wish_name_message_key: null },
}),
],
}),
}),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
const toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent('Sommar')
expect(toast).toHaveTextContent('10')
})
it('shows no toast when a sync with no changes also has no outcome for the touched wish (genuine no-op)', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [],
proposal: makePlanProposal({ outcomes: [] }),
}),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
await waitFor(() => expect(add).toHaveBeenCalled())
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
})
it('reports both what changed and what fell short on a partially-funded sync', async () => {
const created = makeLeaveWish({ id: 'lw-new', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [makePlanChange({ newWishId: 'lw-new' })],
proposal: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-new',
startDate: '2026-06-01',
endDate: '2026-06-14',
weeksWished: 2,
weeksFunded: 1,
shortfallDays: 5,
data: { wish_name: 'Sommar', wish_name_message_key: null },
}),
],
}),
}),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
const toast = await screen.findByTestId('calendar-toast')
// What changed (existing "N day(s) changed" summary) ...
expect(toast).toHaveTextContent('ändrades')
// ... and what fell short.
expect(toast).toHaveTextContent('5')
expect(toast).toHaveTextContent('saknas')
})
it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [],
applyResult: makePlanApplyResponse({
accepted: false,
rejections: [
makeRejection({
messageKey: 'planApply.rejection.insufficientVacationBalance',
data: { parent_personal_data_uuid: 'pd-a', year: 2026, needed: 5, remaining: 2 },
}),
],
}),
}),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'pd-a', name: 'Förälder A' })] }),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
const toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent('Förälder A')
expect(toast.textContent).not.toContain('pd-a')
})
it('undo re-syncs after reverting a period edit to its previous state', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1', name: 'Sommar' })
const update = vi.fn().mockResolvedValue(wish)
// Every call (including the initial auto-sync on mount, which is not
// under test here) reports the same non-empty diff — the point of
// this test is the UNDO's re-sync, not what any particular sync call
// returns.
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
// Let the initial auto-sync (unrelated to this test) settle first.
await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-save'))
const undoButton = await screen.findByTestId('calendar-toast-undo')
const syncCallsBeforeUndo = sync.mock.calls.length
fireEvent.click(undoButton)
await waitFor(() =>
expect(update).toHaveBeenLastCalledWith('lw-1', expect.objectContaining({ name: 'Sommar' })),
)
// Undo re-syncs (days are derived — reverting the wish and re-syncing
// is the correct undo, not touching planned days directly).
await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo))
})
it('dismisses the toast on demand', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
const dismissButton = await screen.findByTestId('calendar-toast-dismiss')
fireEvent.click(dismissButton)
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
})
it('undo after creating a period from a drag removes the wish and re-syncs', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const add = vi.fn().mockResolvedValue(created)
const remove = vi.fn().mockResolvedValue(undefined)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, remove, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
const undoButton = await screen.findByTestId('calendar-toast-undo')
const syncCallsBeforeUndo = sync.mock.calls.length
fireEvent.click(undoButton)
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-new'))
await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo))
})
it('undo after selecting a preset removes the wish and re-syncs', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const remove = vi.fn().mockResolvedValue(undefined)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, remove, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
const undoButton = await screen.findByTestId('calendar-toast-undo')
const syncCallsBeforeUndo = sync.mock.calls.length
fireEvent.click(undoButton)
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-preset'))
await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo))
})
it('undo after deleting an ad-hoc period recreates the deleted days', async () => {
const today = todayDateString()
const remove = vi.fn().mockResolvedValue(undefined)
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: null,
parentPersonalDataUuid: 'person-1',
dayType: 'vacation',
isDubbeldag: false,
isPinned: true,
}),
],
remove,
add,
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete'))
const undoButton = await screen.findByTestId('calendar-toast-undo')
fireEvent.click(undoButton)
await waitFor(() =>
expect(add).toHaveBeenCalledWith(
expect.objectContaining({
workspace_id: 'ws-1',
child_id: 'child-1',
parent_personal_data_uuid: 'person-1',
date: today,
day_type: 'vacation',
is_dubbeldag: false,
is_pinned: true,
}),
),
)
})
})
describe('§Task7 initial auto-sync', () => {
it('syncs once per workspace on mount without user interaction', async () => {
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
const { rerender } = render(<Calendar />)
await waitFor(() => expect(sync).toHaveBeenCalledWith('ws-1'))
rerender(<Calendar />)
// No extra auto-sync from a plain re-render of the same workspace.
expect(sync).toHaveBeenCalledTimes(1)
})
it('does not crash when the workspace id is not yet available', () => {
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({ workspace: null, selectedChildId: null, personalDataUuid: null }),
)
expect(() => render(<Calendar />)).not.toThrow()
expect(mockSync.sync).not.toHaveBeenCalled()
})
})
describe('defensive fallbacks', () => {
it('falls back to safe defaults when workspace/child/personalData are unavailable', () => {
const noWorkspace = makeWorkspaceContextValue({
selectedChildId: null,
workspace: null,
personalDataUuid: null,
})
vi.mocked(useWorkspace).mockReturnValue(noWorkspace)
render(<Calendar />)
expect(usePlannedDays).toHaveBeenCalledWith(undefined)
expect(useCalendarDays).toHaveBeenCalledWith('', expect.any(Number), expect.any(Number))
expect(useDayOverrides).toHaveBeenCalledWith('')
})
it('treats a missing calendar window as having no days yet', () => {
vi.mocked(useCalendarDays).mockReturnValue(makeCalendarDaysResult({ data: null, isLoading: true }))
render(<Calendar />)
expect(document.querySelector('.calendar-day--red-day')).not.toBeInTheDocument()
expect(screen.getByTestId('calendar')).toBeInTheDocument()
})
it('treats missing planned days as an empty list for lookups and bar rendering', () => {
vi.mocked(usePlannedDays).mockReturnValue({ ...mockPlannedDays, data: null })
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'no')
})
it('renders the month/year header in English when the locale is English', () => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
})
try {
render(
<LanguageProvider>
<Calendar />
</LanguageProvider>,
)
const title = screen.getByRole('heading', { level: 2 })
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
expect(monthSelect.getAttribute('aria-label')).toBe('Select month')
} finally {
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
vi.unstubAllGlobals()
}
})
it('shows the week-number column when the preference is enabled', () => {
vi.mocked(useShowWeekNumbers).mockReturnValueOnce(makeShowWeekNumbersTuple(true))
render(<Calendar />)
expect(document.querySelector('.calendar__grid--with-week-numbers')).toBeInTheDocument()
})
it('defaults the new-day popover to "vacation" when there is no last-used leave type', () => {
vi.mocked(getLastLeaveType).mockReturnValueOnce(null)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
expect(screen.getByTestId('modal')).toHaveAttribute('data-default-day-type', 'vacation')
})
it('lets the popover offer deletion when the clicked date already has a planned day', () => {
const today = todayDateString()
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })],
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'yes')
})
it('deleting an existing day through the popover removes it', async () => {
const today = todayDateString()
const remove = vi.fn().mockResolvedValue(undefined)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })],
remove,
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-delete'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1'))
})
it('closes the day popover via its close button', () => {
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
expect(screen.getByTestId('modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-close'))
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
it('shows a generic override error when the mutation rejects with a non-Error value', async () => {
const today = todayDateString()
const upsert = vi.fn().mockRejectedValue('network exploded')
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }),
}),
)
vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() => {
const attr = screen.getByTestId('modal').getAttribute('data-override-error')
expect(attr).not.toBe('')
expect(attr).not.toBe('network exploded')
})
})
it('treats a missing day-overrides list as empty when clearing an override', async () => {
const today = todayDateString()
const removeOverride = vi.fn()
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({
days: [makeCalendarDayEntry({ date: today, isOverride: true, overrideIsWorkingDay: false, isFree: true })],
}),
}),
)
// `data` is undefined here (e.g. before the overrides fetch resolves) —
// the `(dayOverridesHook.data ?? []).find(...)` fallback must kick in.
vi.mocked(useDayOverrides).mockReturnValue({
...makeDayOverridesResult({ isLoading: true, upsert: vi.fn(), remove: removeOverride }),
data: undefined,
} as unknown as ReturnType<typeof useDayOverrides>)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() => expect(screen.getByTestId('modal')).toHaveAttribute('data-override-error', ''))
expect(removeOverride).not.toHaveBeenCalled()
})
it('does not attempt an override toggle when the workspace id is missing', () => {
const upsert = vi.fn()
vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ workspace: null, personalDataUuid: 'person-1' }))
vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
expect(upsert).not.toHaveBeenCalled()
})
it('shows a generic error toast for a non-Error rejection while syncing', async () => {
const sync = vi.fn().mockRejectedValue('boom')
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
await waitFor(() => {
const toastEl = screen.getByTestId('calendar-toast')
expect(toastEl).toHaveTextContent('Det gick inte att synka planen')
})
})
it('shows a generic error toast for a non-Error rejection when creating a period from a drag', async () => {
const add = vi.fn().mockRejectedValue('boom')
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
await waitFor(() =>
expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Det gick inte att skapa perioden'),
)
})
it('does not create a period from a drag when the workspace id is missing', () => {
const add = vi.fn()
vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ workspace: null, personalDataUuid: null }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
expect(add).not.toHaveBeenCalled()
})
it('shows a generic error toast for a non-Error rejection on undo', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const update = vi.fn().mockResolvedValueOnce(wish).mockRejectedValueOnce('boom')
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-save'))
const undoButton = await screen.findByTestId('calendar-toast-undo')
fireEvent.click(undoButton)
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Det gick inte att ångra ändringen'))
})
it('does not save a period edit for an ad-hoc band (no wishId)', async () => {
const today = todayDateString()
const update = vi.fn()
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ update, data: [] }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-save'))
expect(update).not.toHaveBeenCalled()
})
it('shows a generic error when saving a period edit rejects with a non-Error value', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const update = vi.fn().mockRejectedValue('boom')
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-save'))
await waitFor(() => {
const attr = screen.getByTestId('period-popover').getAttribute('data-error')
expect(attr).not.toBe('')
expect(attr).not.toBe('boom')
})
})
it('shows an error toast when deleting a period fails', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const remove = vi.fn().mockRejectedValue(new Error('cannot delete'))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete'))
await waitFor(() => expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'cannot delete'))
})
it('shows the Error message directly when the initial sync rejects with an Error', async () => {
const sync = vi.fn().mockRejectedValue(new Error('workspace not found'))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('workspace not found'))
})
it('does not navigate or flash a band when the preset is created but the follow-up sync fails', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValueOnce(makePlanSyncResponse()).mockRejectedValueOnce(new Error('sync failed'))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
const yearBefore = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('sync failed'))
const yearAfter = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value
expect(yearAfter).toBe(yearBefore)
})
it('actually scrolls the calendar into view when the placement lands after a preset sync', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', {
value: scrollIntoView,
configurable: true,
})
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi
.fn()
.mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset', date: '2031-03-05' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => expect(scrollIntoView).toHaveBeenCalled())
})
it('deleting an ad-hoc band whose days are no longer present shows no toast', async () => {
const today = todayDateString()
// The band's dates exist in the grid, but the underlying planned days
// have already been removed by the time delete runs (a defensive
// fallback path — should not throw or show a toast for zero days).
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
}),
)
const { rerender } = render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
// Swap the data out from under the handler right before deleting —
// requires a re-render for the new hook return value to take effect.
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: [] }))
rerender(<Calendar />)
fireEvent.click(screen.getByTestId('period-delete'))
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
})
it('summarizes a change for a wish not present in the local wish list as a generic period', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'some-other-wish-id' })] }),
)
// The wish list doesn't (yet) include the wish the change refers to.
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Perioden'))
})
})
})