Files
F-r-ldraledigplannerare/frontend/src/pages/Calendar.test.tsx
T
jacobh2 61e5487078 Free days, vacation forfeiture, and global auto-replanning
Free days (Sat/Sun/red days, subject to DayOverride) can never be
vacation, and can only be parental leave when the workspace opts in via
take_pl_on_red_days. The allocator already honoured this; the hand-pin
path bypassed it entirely.

- PlanningService gains optional workspace_repo/day_override_repo deps
  and _reject_illegal_free_day, enforced in add_planned_day and update.
  Reuses allocation_service.is_free_day rather than re-deriving it, and
  no-ops when the deps are unset so existing callers are unaffected.
- update_planned_day had no ValidationError handler at all, so a
  validation failure there 500'd instead of 422'ing. Fixed.
- Calendar filters the pin write loop by the day's isFree; the modal
  explains free days stay off, and that pinning opts a day out of
  automatic re-planning.

Vacation days at risk of forfeiture were computed and sent over the
wire but never rendered: an at-risk column with the age-out/over-cap
split, and a dashboard key figure.

PlanProposal.warnings was likewise parsed but rendered nowhere, for all
three kinds. Adds PlanWarningCard in a Risks section above the tips,
with parent names resolved and dates formatted.

POST /api/plan/sync now fires from the workspace layer on every
plan-input change rather than only from Calendar, so a Settings edit or
a pinned-day save re-plans day types instead of merely refetching.
Overlapping bumps coalesce into one trailing run; a rejection cannot
wedge the in-flight guard. eviction_preview was silently dropped by the
frontend despite always being on the wire, and is now surfaced through
context.

Verified: backend 890 passed (91.49% coverage), frontend 1297 passed,
ruff/eslint/tsc clean. Browser-checked in Swedish and English.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
2026-09-20 13:07:36 +02:00

2519 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { useSearchParams, type SetURLSearchParams } from 'react-router-dom'
import { Calendar } from './Calendar'
import { useWorkspace } from '@/context'
import {
usePlannedDays,
useCalendarDays,
useShowWeekNumbers,
getLastLeaveType,
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
usePlannedDayExclusions,
} 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,
makeShowWeekNumbersTuple,
makePlannedLeaveDay,
makeLeaveWishesResult,
makeWorkspaceMembersResult,
makePlanSyncResult,
makeLeaveWish,
makePersonalData,
makePlanSyncResponse,
makePlanChange,
makeWindowOutcome,
makeRejection,
makePlanProposal,
makePlanApplyResponse,
makePlannedDayExclusionsResult,
makePlannedDayExclusion,
makeWorkspace,
} from '../../tests/fixtures'
import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar'
vi.mock('@/context', () => ({
useWorkspace: vi.fn(),
}))
// Calendar.tsx only reads `useSearchParams` from react-router-dom (no
// `useNavigate`/route matching), so the rest of the module can stay real —
// only the search params themselves need to be controllable per test.
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router-dom')>()
return { ...actual, useSearchParams: vi.fn() }
})
const mockSetSearchParams: SetURLSearchParams = vi.fn()
vi.mock('@/hooks', () => ({
usePlannedDays: vi.fn(),
useShowWeekNumbers: vi.fn(() => [false]),
getLastLeaveType: vi.fn(() => 'vacation'),
setLastLeaveType: vi.fn(),
useCalendarDays: vi.fn(),
useLeaveWishes: vi.fn(),
useWorkspaceMembers: vi.fn(),
usePlanSync: vi.fn(),
usePlannedDayExclusions: vi.fn(),
}))
vi.mock('@/components/layout/Layout', () => ({
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</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). Mirrors the
// real component's `if (!isOpen) return null` — Calendar.tsx can have a
// non-empty `selectedDates` (so `firstSelectedDate` is set, and this mock
// gets rendered) while `isModalOpen` is still false, e.g. right after
// seeding the selection from a `?date=` param without opening the modal.
PlannedDayModal: (props: PlannedDayModalProps) => {
if (!props.isOpen) return null
return (
<div
data-testid="modal"
data-default-day-type={props.defaultDayType ?? ''}
data-existing-day={props.existingDay ? 'yes' : 'no'}
data-parents-count={props.parents.length}
data-is-range-mode={props.isRangeMode ? 'yes' : 'no'}
data-day-count={props.dayCount ?? ''}
data-selected-parent-id={props.selectedParentId}
>
{/* §todo10/11 default path — the planner decides funding. */}
<button
data-testid="modal-save-wish-either"
onClick={() => void props.onSaveWish({ parentMode: 'either', parentPersonalDataUuid: null, wantsDubbeldag: false })}
>
save wish (either)
</button>
<button
data-testid="modal-save-wish-both"
onClick={() => void props.onSaveWish({ parentMode: 'both', parentPersonalDataUuid: null, wantsDubbeldag: false })}
>
save wish (both)
</button>
<button
data-testid="modal-save-wish-both-dubbeldag"
onClick={() => void props.onSaveWish({ parentMode: 'both', parentPersonalDataUuid: null, wantsDubbeldag: true })}
>
save wish (both, dubbeldag)
</button>
<button
data-testid="modal-save-wish-specific"
onClick={() => void props.onSaveWish({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: false })}
>
save wish (specific)
</button>
{/* Deliberately sends `wantsDubbeldag: true` alongside `parentMode:
'specific'` — the real modal itself disables the checkbox outside
'both' mode, but Calendar.tsx's `handleSaveWish` must not trust
that alone; it re-gates on `parentMode` before sending the wish. */}
<button
data-testid="modal-save-wish-specific-dubbeldag-attempt"
onClick={() => void props.onSaveWish({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: true })}
>
save wish (specific, dubbeldag attempt)
</button>
{/* The "Advanced" escape hatch — pins an exact day type for whichever
parent `selectedParentId` currently names (Calendar.tsx's lifted
`advancedParentId`, updated live via `onParentChange`). */}
<button
data-testid="modal-save-pinned"
onClick={() => void props.onSavePinned({ dayType: 'vacation', parentId: props.selectedParentId, isDubbeldag: false })}
>
save pinned (vacation)
</button>
<button
data-testid="modal-save-pinned-sjukpenning"
onClick={() => void props.onSavePinned({ dayType: 'sjukpenning', parentId: props.selectedParentId, isDubbeldag: false })}
>
save pinned (sjukpenning)
</button>
<button data-testid="modal-change-parent-b" onClick={() => props.onParentChange?.('pd-b')}>
switch advanced parent to B
</button>
{props.onDelete && (
<button data-testid="modal-delete" onClick={() => void props.onDelete?.()}>
delete
</button>
)}
{(props.excludedEntries ?? []).map((entry) => (
<button
key={entry.id}
data-testid={`modal-restore-excluded-${entry.id}`}
data-parent-name={entry.parentName}
onClick={() => void props.onRestoreExcluded?.(entry.id)}
>
restore {entry.parentName}
</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-remove-day-label={props.removeDayLabel ?? ''}
data-remove-day-parent={props.removeDayParentName ?? ''}
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',
scope: 'occurrence',
lastYear: null,
wantsDubbeldag: false,
} satisfies PeriodPopoverSavePayload)
}
>
save
</button>
<button
data-testid="period-save-series"
onClick={() =>
void props.onSave({
name: 'Edited name',
startDate: props.startDate,
endDate: props.endDate,
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'yearly',
scope: 'series',
lastYear: 2031,
wantsDubbeldag: false,
} satisfies PeriodPopoverSavePayload)
}
>
save (every year)
</button>
<button
data-testid="period-save-once"
onClick={() =>
void props.onSave({
name: 'Edited name',
startDate: props.startDate,
endDate: props.endDate,
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'once',
scope: 'occurrence',
lastYear: null,
wantsDubbeldag: false,
} satisfies PeriodPopoverSavePayload)
}
>
save once
</button>
{/* §todo9 — the three-way remove. */}
<button data-testid="period-delete-day" onClick={() => void props.onDelete('day')}>
delete (day)
</button>
<button data-testid="period-delete-year" onClick={() => void props.onDelete('year')}>
delete (year)
</button>
<button data-testid="period-delete-all-years" onClick={() => void props.onDelete('allYears')}>
delete (all years)
</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 mockLeaveWishes = makeLeaveWishesResult()
const mockMembers = makeWorkspaceMembersResult()
const mockSync = makePlanSyncResult()
const mockExclusions = makePlannedDayExclusionsResult()
beforeEach(() => {
vi.mocked(useCalendarDays).mockReturnValue(mockCalendarWindow)
vi.mocked(useLeaveWishes).mockReturnValue(mockLeaveWishes)
vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers)
vi.mocked(usePlanSync).mockReturnValue(mockSync)
vi.mocked(usePlannedDayExclusions).mockReturnValue(mockExclusions)
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams])
})
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.getByTestId('page-skeleton')).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('does not render a phantom trailing week when the month ends exactly on a Sunday', () => {
// May 2026 has 31 days, starting on a Friday: 4 leading days + 31 = 35,
// an exact multiple of 7 — the naive "always pad and push a trailing
// week" logic would add a 6th row of 7 disabled June cells that serves
// no purpose.
render(<Calendar />)
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
fireEvent.change(yearSelect, { target: { value: '2026' } })
fireEvent.change(monthSelect, { target: { value: '4' } })
const calendarDays = screen.getAllByTestId('calendar-day')
expect(calendarDays.length).toBe(35)
})
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('opens the day popover when clicking a disabled (adjacent-month) day — planning a visible day outside the displayed month is deliberate, not dead', () => {
// Decided behaviour for the drag-across-month-boundary fix: a leading/
// trailing cell stays dimmed (`calendar-day--disabled` — see
// CalendarDay.tsx) but is still clickable/drag-selectable, same as any
// other visible day. Silently doing nothing on a visible cell was the
// original bug (see CalendarWeek.tsx's day-cell handlers).
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')
expect(screen.getByTestId('modal')).toBeInTheDocument()
}
})
it('displays the month and year as real title text, capitalised', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
expect(title.textContent).toMatch(
/^(Januari|Februari|Mars|April|Maj|Juni|Juli|Augusti|September|Oktober|November|December) \d{4}$/,
)
// The title holds no controls: the month/year selects live beside it.
expect(title.querySelector('select')).toBeNull()
})
it('renders the help popover button in place of the old legend', () => {
render(<Calendar />)
expect(screen.getByTestId('calendar-help-button')).toBeInTheDocument()
expect(document.querySelector('.calendar__legend')).toBeNull()
})
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 monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') 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('Today button jumps back to the current month/year after navigating away', () => {
render(<Calendar />)
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
fireEvent.change(monthSelect, { target: { value: '5' } })
fireEvent.change(yearSelect, { target: { value: '2035' } })
expect(yearSelect.value).toBe('2035')
fireEvent.click(screen.getByText('Idag'))
const today = new Date()
expect(monthSelect.value).toBe(String(today.getMonth()))
expect(yearSelect.value).toBe(String(today.getFullYear()))
})
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 yearSelect = screen.getByLabelText('Välj år') 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 yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(options).toContain(new Date().getFullYear())
})
describe('?date= query param seeds the initial month/selection', () => {
it('opens the month of a valid future ?date= and highlights that day, without opening the modal', () => {
vi.mocked(useSearchParams).mockReturnValue([
new URLSearchParams('date=2030-07-15'),
mockSetSearchParams,
])
render(<Calendar />)
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
expect(yearSelect.value).toBe('2030')
expect(monthSelect.value).toBe('6') // July is month index 6
const targetDay = screen
.getAllByTestId('calendar-day')
.find((el) => el.getAttribute('aria-label')?.startsWith('2030-07-15'))
expect(targetDay).toHaveClass('calendar-day--selected')
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
it('falls back to today for an invalid/garbage ?date= param', () => {
vi.mocked(useSearchParams).mockReturnValue([
new URLSearchParams('date=not-a-real-date'),
mockSetSearchParams,
])
render(<Calendar />)
const today = new Date()
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
expect(yearSelect.value).toBe(String(today.getFullYear()))
expect(monthSelect.value).toBe(String(today.getMonth()))
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
it('falls back to today when no ?date= param is present', () => {
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams])
render(<Calendar />)
const today = new Date()
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
expect(yearSelect.value).toBe(String(today.getFullYear()))
expect(monthSelect.value).toBe(String(today.getMonth()))
})
})
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()
})
})
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-pinned'))
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-pinned'))
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-pinned'))
// 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')
})
it('labels a partner-owned band with their actual name, not the generic "Partner" string', () => {
const today = todayDateString()
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Sam' })],
}),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'pd-b' })],
}),
)
render(<Calendar />)
expect(screen.getByText(/Sam ·/)).toBeInTheDocument()
expect(screen.queryByText(/^Partner ·/)).not.toBeInTheDocument()
})
})
describe('§Task7 drag-select opens the day popover on the exact dragged days', () => {
it('opens the day popover pre-selected on every dragged date, without creating a wish', async () => {
const add = vi.fn()
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'))
const startDay = currentMonthDays[0] as HTMLElement
const endDay = currentMonthDays[3] as HTMLElement
fireEvent.pointerDown(startDay)
fireEvent.pointerEnter(endDay)
fireEvent.pointerUp(window)
const modal = await screen.findByTestId('modal')
expect(modal).toHaveAttribute('data-is-range-mode', 'yes')
expect(modal).toHaveAttribute('data-day-count', '4')
// A drag (single- or multi-day) never creates a LeaveWish/synced
// period on its own anymore — only the popover's own save does
// anything, and that writes planned days directly.
expect(add).not.toHaveBeenCalled()
})
it('opens the day popover in single mode 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)
const modal = screen.getByTestId('modal')
expect(modal).toBeInTheDocument()
expect(modal).toHaveAttribute('data-is-range-mode', 'no')
})
it('a leading week\'s in-month days are draggable (regression: the week used to be gated by weekDates[0]\'s month, not the displayed month)', () => {
render(<Calendar />)
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
// March 2026 starts on a Sunday, so its first calendar row is a
// leading week made up of six February days plus March 1st.
fireEvent.change(yearSelect, { target: { value: '2026' } })
fireEvent.change(monthSelect, { target: { value: '2' } })
const calendarDays = screen.getAllByTestId('calendar-day')
const marchFirst = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith('2026-03-01'))!
const febTail = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith('2026-02-23'))!
// The previous month's tail day in that same row stays inert...
expect(febTail).toHaveClass('calendar-day--disabled')
// ...but March 1st — in-month, in the very same leading week — is not.
expect(marchFirst).not.toHaveClass('calendar-day--disabled')
fireEvent.pointerDown(marchFirst)
fireEvent.pointerUp(window)
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
})
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' }),
'occurrence',
),
)
await waitFor(() => expect(sync).toHaveBeenCalled())
expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
})
it('saving a period edit pins exact dates: windowMode flips to fixed with a matching target', async () => {
// §bug — a preset-born wish is `windowMode: 'flexible'` (a bounding
// range the planner searches inside, not a placement). Once the user
// has dragged/typed exact dates in the popover, those dates ARE the
// placement, so the save must flip the row to 'fixed' with a target
// matching the edited span. Leaving it 'flexible' turns the (now
// week-long) bound into a single flexible candidate, which repeats the
// same calendar date every year on a drifting weekday instead of
// preserving the weekday.
const wish = makeLeaveWish({ id: 'lw-1', windowMode: 'flexible' })
const update = vi.fn().mockResolvedValue(wish)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-05'), mockSetSearchParams])
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: '2026-10-05', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-2', date: '2026-10-06', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-3', date: '2026-10-07', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-4', date: '2026-10-08', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-5', date: '2026-10-09', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-6', date: '2026-10-10', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-7', date: '2026-10-11', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-start', '2026-10-05')
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-end', '2026-10-11')
fireEvent.click(screen.getByTestId('period-save'))
await waitFor(() =>
expect(update).toHaveBeenCalledWith(
'lw-1',
expect.objectContaining({ windowMode: 'fixed', targetDays: 7, targetWeeks: 1 }),
'occurrence',
),
)
})
it('saving one year of a repeating wish writes that year and keeps the scope narrow', async () => {
// The band clicked here is the 2028 row of a series. Its own year IS
// what gets written — the old shape had to keep the wish's anchor year
// and rewrite the shared month/day pattern for every year, because
// writing `firstYear: 2028` would have dropped the earlier occurrences.
// There are no "earlier occurrences" on this row to drop any more.
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' })
const update = vi.fn().mockResolvedValue(wish)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams])
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: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-start', '2028-06-15')
fireEvent.click(screen.getByTestId('period-save'))
await waitFor(() =>
expect(update).toHaveBeenCalledWith(
'lw-1',
expect.objectContaining({ firstYear: 2028 }),
'occurrence',
),
)
// `lastYear` is series-level, so a this-year-only edit must not carry it.
expect(update.mock.calls[0]![1]).not.toHaveProperty('lastYear')
})
it('saving "every year" widens the scope and carries the series end year', async () => {
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' })
const update = vi.fn().mockResolvedValue(wish)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams])
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: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-save-series'))
await waitFor(() =>
expect(update).toHaveBeenCalledWith(
'lw-1',
expect.objectContaining({ firstYear: 2028, lastYear: 2031 }),
'series',
),
)
})
it('switching an occurrence to "once" detaches it from the series at its own year', async () => {
// 'once' on a series occurrence means "stop repeating": the clicked
// year survives (at its own year, which is what the popover shows) and
// the siblings go. That is series-wide by definition, so the popover
// sends the narrow scope and the backend still drops the siblings.
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' })
const update = vi.fn().mockResolvedValue(wish)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams])
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: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-save-once'))
await waitFor(() =>
expect(update).toHaveBeenCalledWith(
'lw-1',
expect.objectContaining({ firstYear: 2028, recurrence: 'once' }),
'occurrence',
),
)
})
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-all-years'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'series'))
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-all-years'))
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-all-years'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'series'))
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-year'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1'))
// An ad-hoc delete removes the planned day directly and never syncs
// (it isn't wish-driven) — Calendar itself performs no auto-sync of
// its own any more (see the "§Task7 auto-sync moved to
// PlanSyncProvider" describe block below).
expect(sync).not.toHaveBeenCalled()
})
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('defaults a solo household preset to "either" parent', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'pd-a', name: 'Förälder A' })] }),
)
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({ parentMode: 'either' })))
})
it('defaults a two-parent household preset to "both" parents', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [
makePersonalData({ uuid: 'pd-a', name: 'Förälder A' }),
makePersonalData({ uuid: 'pd-b', name: 'Förälder B' }),
],
}),
)
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({ parentMode: 'both' })))
})
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 yearSelect = screen.getByLabelText('Välj år') 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', () => {
// Only the preset rail and period-popover edits still create/sync a
// `LeaveWish` — a drag no longer does (it just plans exact days
// directly, with no sync/toast). These tests drive the toast/undo
// machinery through the preset rail instead.
it('shows a toast summarizing what changed after a sync with changes', async () => {
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' }), makePlanChange({ newWishId: 'lw-preset' })] }),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
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-preset' })
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 />)
fireEvent.click(screen.getByTestId('preset-click'))
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-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [],
proposal: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-preset',
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 />)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent('Sommar')
expect(toast).toHaveTextContent('10')
})
it('reports a period\'s shortfall in days, not weeks, when the wish carries an exact targetDays', async () => {
// `targetDays` (an exact day count) can still be set on a wish (e.g.
// one created before this drag-select rework, or seeded directly);
// `targetWeeks` on that same wish is just a rough
// `Math.ceil(days / 7)` leftover the API still requires — never what
// the user actually asked for. The toast must report the days
// figure, not `weeksWished`, once `targetDays` is set. `weeksWished`
// is deliberately set to a nonsense value here (3) to prove the toast
// isn't reading it at all, not just that it happens to agree. Driven
// through a period-popover edit/save (band click), since no UI path
// creates a fresh wish with `targetDays` anymore.
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1', name: 'Ny period', targetDays: 7, targetWeeks: 1 })
const update = vi.fn().mockResolvedValue(wish)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [],
proposal: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-1',
startDate: '2026-09-17',
endDate: '2026-09-23',
weeksWished: 3,
weeksFunded: 0,
shortfallDays: 5,
data: { wish_name: 'Ny period', wish_name_message_key: null },
}),
],
}),
}),
)
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 toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent('7')
expect(toast).toHaveTextContent('dagar')
expect(toast).not.toHaveTextContent('3 vecko')
expect(toast).not.toHaveTextContent('veckorna')
})
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-preset' })
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 />)
fireEvent.click(screen.getByTestId('preset-click'))
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-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [makePlanChange({ newWishId: 'lw-preset' })],
proposal: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-preset',
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 />)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
// What changed (the "N days changed" summary with pluralization) ...
expect(toast).toHaveTextContent('ändrades')
// ... and what fell short.
expect(toast).toHaveTextContent('5')
expect(toast).toHaveTextContent('saknas')
})
it('shows a toast when a flexible window auto-shifted to catch extra red days', async () => {
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [makePlanChange({ newWishId: 'lw-preset' })],
proposal: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-preset',
shiftGainDays: 2,
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 />)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent('Sommar')
expect(toast).toHaveTextContent('flyttad')
expect(toast).toHaveTextContent('2')
})
it('does not show the shift toast when the wish with a positive shiftGainDays is not in changes', async () => {
// A plain re-open (nothing actually touched this wish's dates this
// sync) must never re-announce a stable, already-applied shift --
// `changes` membership is what distinguishes the two, not
// `shiftGainDays > 0` alone.
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [],
proposal: makePlanProposal({
outcomes: [makeWindowOutcome({ wishId: 'lw-preset', shiftGainDays: 2 })],
}),
}),
)
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).toHaveBeenCalled())
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
})
it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
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 />)
fireEvent.click(screen.getByTestId('preset-click'))
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 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 />)
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' }),
'occurrence',
),
)
// 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-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
const dismissButton = await screen.findByTestId('calendar-toast-dismiss')
fireEvent.click(dismissButton)
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
})
it('undo after selecting a preset deletes the wish and re-syncs, leaving day cleanup to the backend', 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 }))
const removeDay = vi.fn().mockResolvedValue(undefined)
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ remove: removeDay }))
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)
// The preset is a repeating wish, so undo takes the whole series.
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-preset', 'series'))
await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo))
expect(removeDay).not.toHaveBeenCalled()
})
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-year'))
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 auto-sync moved to PlanSyncProvider', () => {
it('performs no sync of its own on mount — that is now PlanSyncProvider, mounted globally inside WorkspaceProvider', () => {
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
expect(sync).not.toHaveBeenCalled()
})
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))
})
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>,
)
expect(screen.getByLabelText('Select month')).toBeInTheDocument()
// The title is real text now, in the English month name.
expect(screen.getByRole('heading', { level: 2 }).textContent).toMatch(
/^(January|February|March|April|May|June|July|August|September|October|November|December) \d{4}$/,
)
} 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 error toast for a non-Error rejection while syncing', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockRejectedValue('boom')
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
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 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-all-years'))
await waitFor(() => expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'cannot delete'))
})
it('shows the Error message directly when a sync rejects with an Error', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockRejectedValue(new Error('workspace not found'))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
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().mockRejectedValue(new Error('sync failed'))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const yearBefore = (screen.getByLabelText('Välj år') as HTMLSelectElement).value
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('sync failed'))
const yearAfter = (screen.getByLabelText('Välj år') 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-year'))
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-preset' })
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 />)
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Perioden'))
})
})
describe('§Task7 drag-select range save writes every dragged day', () => {
it('writes every dragged date as its own planned day (add for new dates, update for existing ones), all pinned', async () => {
// Render once first to read off the actual dates the drag will cover
// (independent of which day of the month the suite happens to run
// on), then re-render with a pre-existing planned day on the first
// of those dates — the save must update it in place, not skip or
// duplicate it.
const { unmount } = render(<Calendar />)
const probeDays = screen.getAllByTestId('calendar-day').filter((el) => !el.classList.contains('calendar-day--disabled'))
const firstDate = probeDays[0]!.getAttribute('aria-label')!.slice(0, 10)
unmount()
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
const update = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-existing', date: firstDate, parentPersonalDataUuid: 'person-1' })],
add,
update,
}),
)
render(<Calendar />)
const currentMonthDays = screen.getAllByTestId('calendar-day').filter((el) => !el.classList.contains('calendar-day--disabled'))
const startDay = currentMonthDays[0] as HTMLElement
const endDay = currentMonthDays[2] as HTMLElement
fireEvent.pointerDown(startDay)
fireEvent.pointerEnter(endDay)
fireEvent.pointerUp(window)
const modal = await screen.findByTestId('modal')
expect(modal).toHaveAttribute('data-day-count', '3')
fireEvent.click(screen.getByTestId('modal-save-pinned'))
// The pre-existing day is updated (pinned), the other two are added
// (also pinned) — never a LeaveWish, never a sync.
await waitFor(() => expect(update).toHaveBeenCalledWith('pd-existing', expect.objectContaining({ is_pinned: true })))
await waitFor(() => expect(add).toHaveBeenCalledTimes(2))
expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true }))
await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument())
})
})
describe('hand-pin path never lets a free day become vacation (§ free days never consume vacation)', () => {
// June 2026: the 1st is a Monday, so 06-01..06-07 is a clean Mon–Sun
// week with 06-06/06-07 as the only free (weekend) days in it.
const MON = '2026-06-01'
const SUN = '2026-06-07'
const SAT_DATE = '2026-06-06'
const SUN_DATE = '2026-06-07'
function mockFreeWeekend() {
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({
days: [
makeCalendarDayEntry({ date: SAT_DATE, dayType: 'weekend', isFree: true }),
makeCalendarDayEntry({ date: SUN_DATE, dayType: 'weekend', isFree: true }),
],
}),
}),
)
}
function selectMonToSun() {
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(`date=${MON}`), mockSetSearchParams])
render(<Calendar />)
const days = screen.getAllByTestId('calendar-day')
const startDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(MON))!
const endDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SUN))!
fireEvent.pointerDown(startDay)
fireEvent.pointerEnter(endDay)
fireEvent.pointerUp(window)
}
it('pinning a Mon–Sun range as vacation creates 5 rows, skipping the weekend', async () => {
mockFreeWeekend()
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
selectMonToSun()
const modal = await screen.findByTestId('modal')
expect(modal).toHaveAttribute('data-day-count', '7')
fireEvent.click(screen.getByTestId('modal-save-pinned'))
await waitFor(() => expect(add).toHaveBeenCalledTimes(5))
const pinnedDates = add.mock.calls.map((call) => (call[0] as { date: string }).date)
expect(pinnedDates).not.toContain(SAT_DATE)
expect(pinnedDates).not.toContain(SUN_DATE)
})
it('pinning the same range as sjukpenning with takePlOnRedDays on creates all 7 rows', async () => {
mockFreeWeekend()
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
selectedChildId: 'child-1',
personalDataUuid: 'person-1',
workspace: makeWorkspace({ takePlOnRedDays: true }),
}),
)
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
selectMonToSun()
const modal = await screen.findByTestId('modal')
expect(modal).toHaveAttribute('data-day-count', '7')
fireEvent.click(screen.getByTestId('modal-save-pinned-sjukpenning'))
await waitFor(() => expect(add).toHaveBeenCalledTimes(7))
})
it('shows an error and saves nothing when every selected date is free and ineligible', async () => {
mockFreeWeekend()
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(`date=${SAT_DATE}`), mockSetSearchParams])
render(<Calendar />)
const days = screen.getAllByTestId('calendar-day')
const startDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SAT_DATE))!
const endDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SUN_DATE))!
fireEvent.pointerDown(startDay)
fireEvent.pointerEnter(endDay)
fireEvent.pointerUp(window)
const modal = await screen.findByTestId('modal')
expect(modal).toHaveAttribute('data-day-count', '2')
fireEvent.click(screen.getByTestId('modal-save-pinned'))
await waitFor(() => expect(screen.getByTestId('modal')).toBeInTheDocument())
expect(add).not.toHaveBeenCalled()
})
})
describe('grid layout: uniform cell size and row height regardless of content', () => {
it('keeps every week row on the same shared grid definition even when one week contains a long holiday name', () => {
// Regression guard for the "31 October" bug: a long holiday name
// ("Alla helgons dag") used to widen only the week row containing it,
// because each week is its own CSS Grid container and a bare `1fr`
// track's minimum is its content's own width. jsdom does no layout,
// so this can't assert pixel widths directly — instead it asserts the
// structural invariant (every week row uses the exact same
// `.calendar-week` class, with no per-row width/grid override) and
// that the shared stylesheet rule those rows resolve against is
// actually content-safe (`minmax(0, 1fr)`, not a bare `1fr`).
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({
days: [
makeCalendarDayEntry({
date: '2026-10-31',
name: 'Alla helgons dag',
dayType: 'red_day',
isFree: true,
}),
],
}),
}),
)
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-15'), mockSetSearchParams])
const { container } = render(<Calendar />)
// The long holiday name actually rendered somewhere in the grid.
expect(screen.getByText('Alla helgons dag')).toBeInTheDocument()
// Every week row shares the exact same class — none carries its own
// width/grid-template-columns override that could diverge per row.
const weekRows = Array.from(container.querySelectorAll('.calendar-week'))
expect(weekRows.length).toBeGreaterThan(3)
weekRows.forEach((row) => {
expect(row.className).toBe('calendar-week')
expect((row as HTMLElement).style.width).toBe('')
expect((row as HTMLElement).style.gridTemplateColumns).toBe('')
})
// The stylesheet rule those rows all resolve against must be immune
// to content width: `minmax(0, 1fr)` tracks, and every element
// inside a cell that could carry a long string sets `min-width: 0`.
const css = fs.readFileSync(path.resolve(__dirname, 'calendar.css'), 'utf-8')
expect(css).toMatch(/\.calendar-week\s*{[^}]*grid-template-columns:\s*repeat\(7,\s*minmax\(0,\s*1fr\)\)/)
expect(css).toMatch(
/\.calendar-week--with-week-numbers\s*{[^}]*grid-template-columns:\s*50px repeat\(7,\s*minmax\(0,\s*1fr\)\)/,
)
expect(css).toMatch(/\.calendar-day\s*{[^}]*min-width:\s*0/)
expect(css).toMatch(/\.calendar-week__day-cell\s*{[^}]*min-width:\s*0/)
expect(css).toMatch(/\.calendar-day__holiday-name\s*{[^}]*min-width:\s*0/)
// The height half of the same invariant. Each `.calendar-week` is its
// own grid container, so a cell carrying an extra line (the holiday
// name) grew only ITS row — October 2026's last row rendered 3.5px
// taller than the other four. `.calendar__weeks` is therefore a grid
// with `grid-auto-rows: 1fr`, which on an auto-height container sizes
// every row to the tallest one. jsdom does no layout, so this can only
// be asserted against the stylesheet text, exactly like the width
// rules above — `e2e/tests/calendar-grid-uniform.spec.ts` is what
// measures it in a real browser.
expect(css).toMatch(/\.calendar__weeks\s*{[^}]*grid-auto-rows:\s*1fr/)
expect(css).toMatch(/\.calendar__weeks\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/)
})
it('reserves the same --band-reserved-height on every week row, even though only one week has a band', () => {
// Regression guard: `--band-reserved-height` used to be computed per
// week from that week's OWN max concurrent lane count, so a week with
// a band was taller than a quiet week in the same month. Every row of
// the displayed month must reserve the same height now.
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2026-10-05', parentPersonalDataUuid: 'person-1' })],
}),
)
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-15'), mockSetSearchParams])
const { container } = render(<Calendar />)
const weekRows = Array.from(container.querySelectorAll('.calendar-week')) as HTMLElement[]
expect(weekRows.length).toBeGreaterThan(3)
const reservedHeights = weekRows.map((row) => row.style.getPropertyValue('--band-reserved-height'))
// Every row reserves the SAME height, and it's non-zero (the month
// has at least one band) — a week with no band of its own must still
// match the tallest week's reservation, or its row would render
// shorter than the others.
expect(new Set(reservedHeights).size).toBe(1)
expect(reservedHeights[0]).not.toBe('0px')
})
})
describe('todo.md #14 — two parents cannot both be given the same days', () => {
it('saving the pinned/advanced path targets the modal-selected parent, never the logged-in one', async () => {
const today = todayDateString()
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
const update = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Förälder B' })],
}),
)
// Parent A already has a pinned sjukpenning day on `today` — this is
// exactly the state the bug report described after "parent A takes 2
// days on PL".
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-a-day',
date: today,
parentPersonalDataUuid: 'person-1',
dayType: 'sjukpenning',
isPinned: true,
}),
],
add,
update,
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))!
fireEvent.click(todayCell)
// Opens defaulted to A (the logged-in parent) — A's existing day shows.
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'yes')
expect(screen.getByTestId('modal')).toHaveAttribute('data-selected-parent-id', 'person-1')
// Switch the advanced parent to B — B has nothing here yet.
fireEvent.click(screen.getByTestId('modal-change-parent-b'))
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'no')
expect(screen.getByTestId('modal')).toHaveAttribute('data-selected-parent-id', 'pd-b')
// Now select vacation for B and save through the advanced/pinned path.
fireEvent.click(screen.getByTestId('modal-save-pinned'))
// A's row must never be touched — the old bug called `update` on it.
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ parent_personal_data_uuid: 'pd-b', day_type: 'vacation' })))
expect(update).not.toHaveBeenCalled()
})
it('deleting through the advanced path removes the currently-selected parent\'s day, not the other parent\'s', async () => {
const today = todayDateString()
const remove = vi.fn().mockResolvedValue(undefined)
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Förälder B' })],
}),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({ id: 'pd-a-day', date: today, parentPersonalDataUuid: 'person-1', dayType: 'sjukpenning' }),
makePlannedLeaveDay({ id: 'pd-b-day', date: today, parentPersonalDataUuid: 'pd-b', dayType: 'vacation' }),
],
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-change-parent-b'))
fireEvent.click(screen.getByTestId('modal-delete'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-b-day'))
})
})
describe('todo.md #10/#11 — the manual-add modal defaults to a planner-funded wish', () => {
it('a click/drag by default creates a fixed, once-off wish (not a pinned day) for the chosen parent mode', async () => {
const created = makeLeaveWish({ id: 'lw-manual' })
const add = vi.fn().mockResolvedValue(created)
const addDay = vi.fn()
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add: addDay }))
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-wish-both'))
await waitFor(() =>
expect(add).toHaveBeenCalledWith(
expect.objectContaining({ windowMode: 'fixed', recurrence: 'once', parentMode: 'both', wantsDubbeldag: false }),
),
)
await waitFor(() => expect(sync).toHaveBeenCalled())
// The default path never touches planned days directly.
expect(addDay).not.toHaveBeenCalled()
})
it('forwards wantsDubbeldag only when the wish targets both parents', async () => {
const created = makeLeaveWish({ id: 'lw-manual' })
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 currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
fireEvent.click(currentMonthDay)
fireEvent.click(screen.getByTestId('modal-save-wish-both-dubbeldag'))
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: true })))
})
it('never sends wantsDubbeldag for a specific-parent wish, even if the modal tried to', async () => {
const created = makeLeaveWish({ id: 'lw-manual' })
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 currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
fireEvent.click(currentMonthDay)
fireEvent.click(screen.getByTestId('modal-save-wish-specific-dubbeldag-attempt'))
await waitFor(() =>
expect(add).toHaveBeenCalledWith(
expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: false }),
),
)
})
})
describe('todo.md #9 — the three-way remove', () => {
// Intent changed (was: '"day" scope is a no-op for a wish-linked band').
// The user asked for it to work: a single day removed from a
// planner-placed period is now recorded as a `PlannedDayExclusion`, which
// is what stops the next sync re-deriving it.
it('"day" scope records an exclusion for a wish-linked band, keyed on the band\'s parent', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const remove = vi.fn().mockResolvedValue(undefined)
const update = vi.fn().mockResolvedValue(wish)
const exclude = vi.fn().mockResolvedValue(makePlannedDayExclusion({ id: 'ex-1' }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, update }))
vi.mocked(usePlannedDayExclusions).mockReturnValue(
makePlannedDayExclusionsResult({ exclude }),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: 'lw-1',
parentPersonalDataUuid: 'person-1',
childId: 'child-1',
}),
],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete-day'))
await waitFor(() =>
expect(exclude).toHaveBeenCalledWith({
workspaceId: 'ws-1',
childId: 'child-1',
parentPersonalDataUuid: 'person-1',
date: today,
}),
)
// The wish itself is untouched — only this one date goes.
expect(remove).not.toHaveBeenCalled()
expect(update).not.toHaveBeenCalled()
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
})
it('offers an undo that restores the removed day', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const exclude = vi.fn().mockResolvedValue(makePlannedDayExclusion({ id: 'ex-9' }))
const restore = vi.fn().mockResolvedValue(undefined)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] }))
vi.mocked(usePlannedDayExclusions).mockReturnValue(
makePlannedDayExclusionsResult({ exclude, restore }),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: 'lw-1',
parentPersonalDataUuid: 'person-1',
childId: 'child-1',
}),
],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete-day'))
await waitFor(() => expect(screen.getByTestId('calendar-toast-undo')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('calendar-toast-undo'))
await waitFor(() => expect(restore).toHaveBeenCalledWith('ex-9'))
})
it('offers the removed day back from the day popover, long after the toast is gone', async () => {
const today = todayDateString()
const restore = vi.fn().mockResolvedValue(undefined)
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'person-1', name: 'Alex' })],
}),
)
vi.mocked(usePlannedDayExclusions).mockReturnValue(
makePlannedDayExclusionsResult({
restore,
data: [
makePlannedDayExclusion({
id: 'ex-7',
date: today,
parentPersonalDataUuid: 'person-1',
}),
],
}),
)
render(<Calendar />)
const todayCell = screen
.getAllByTestId('calendar-day')
.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
const restoreButton = await screen.findByTestId('modal-restore-excluded-ex-7')
expect(restoreButton).toHaveAttribute('data-parent-name', 'Alex')
fireEvent.click(restoreButton)
await waitFor(() => expect(restore).toHaveBeenCalledWith('ex-7'))
})
it('tells the popover which date and which parent "this day" would remove', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] }))
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'person-1', name: 'Alex' })],
}),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: 'lw-1',
parentPersonalDataUuid: 'person-1',
childId: 'child-1',
}),
],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
const popover = screen.getByTestId('period-popover')
expect(popover).toHaveAttribute('data-remove-day-parent', 'Alex')
expect(popover.getAttribute('data-remove-day-label')).toBeTruthy()
})
it('"every year" scope is a no-op for an ad-hoc band (disabled in the UI; nothing recurring to remove)', async () => {
const today = todayDateString()
const removeDay = vi.fn()
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
remove: removeDay,
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete-all-years'))
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
expect(removeDay).not.toHaveBeenCalled()
})
it('"day" scope on an ad-hoc band removes only the clicked occurrence\'s first date', async () => {
const removeDay = vi.fn().mockResolvedValue(undefined)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({ id: 'pd-1', date: '2027-03-10', wishId: null, parentPersonalDataUuid: 'person-1' }),
makePlannedLeaveDay({ id: 'pd-2', date: '2027-03-11', wishId: null, parentPersonalDataUuid: 'person-1' }),
],
remove: removeDay,
}),
)
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2027-03-10'), mockSetSearchParams])
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete-day'))
await waitFor(() => expect(removeDay).toHaveBeenCalledWith('pd-1'))
expect(removeDay).not.toHaveBeenCalledWith('pd-2')
})
describe('"year" scope on a recurring wish — one row, one delete', () => {
// Each year of a repeating wish is its own row now, so removing one is
// a plain delete. The old shape had to narrow `firstYear`/`lastYear` —
// and, for a middle year, split the wish into two wishes. Both of those
// contortions are gone; the tests that described them are replaced by
// the ones below, which assert the behaviour the user asked for
// (remove exactly this year, leave the rest alone).
const seriesWish = (firstYear: number) =>
makeLeaveWish({ id: 'lw-1', seriesId: 'series-1', firstYear, lastYear: 2030 })
const renderWithBand = (wish: ReturnType<typeof makeLeaveWish>, isoDate: string) => {
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: isoDate,
wishId: wish.id,
parentPersonalDataUuid: 'person-1',
}),
],
}),
)
vi.mocked(useSearchParams).mockReturnValue([
new URLSearchParams(`date=${isoDate}`),
mockSetSearchParams,
])
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
}
it('removes just the clicked year\'s row, leaving the rest of the series alone', async () => {
const wish = seriesWish(2028)
const remove = vi.fn().mockResolvedValue(undefined)
const update = vi.fn()
const add = vi.fn()
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useLeaveWishes).mockReturnValue(
makeLeaveWishesResult({ data: [wish], remove, update, add }),
)
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
renderWithBand(wish, '2028-06-15')
fireEvent.click(screen.getByTestId('period-delete-year'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'occurrence'))
// No range-narrowing, and above all no second wish: the split hack is gone.
expect(update).not.toHaveBeenCalled()
expect(add).not.toHaveBeenCalled()
})
it('undo puts the removed year back INTO its series, not beside it', async () => {
const wish = seriesWish(2028)
const remove = vi.fn().mockResolvedValue(undefined)
const add = vi.fn().mockResolvedValue(wish)
const sync = vi
.fn()
.mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ oldWishId: 'lw-1', newWishId: null, kind: 'removed' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, add }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
renderWithBand(wish, '2028-06-15')
fireEvent.click(screen.getByTestId('period-delete-year'))
const undoButton = await screen.findByTestId('calendar-toast-undo')
fireEvent.click(undoButton)
await waitFor(() =>
expect(add).toHaveBeenCalledWith(
expect.objectContaining({ firstYear: 2028, seriesId: 'series-1', recurrence: 'once' }),
),
)
})
it('removing the first year of a series is the same plain delete', async () => {
const wish = seriesWish(2026)
const remove = vi.fn().mockResolvedValue(undefined)
const update = vi.fn()
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, update }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
renderWithBand(wish, '2026-06-15')
fireEvent.click(screen.getByTestId('period-delete-year'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'occurrence'))
expect(update).not.toHaveBeenCalled()
})
it('a once-off wish treats "this year" the same as "every year" (there is only one occurrence)', async () => {
const wish = makeLeaveWish({ id: 'lw-1', seriesId: null, firstYear: 2026 })
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 }))
renderWithBand(wish, '2026-06-15')
fireEvent.click(screen.getByTestId('period-delete-year'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'occurrence'))
})
it('undo after removing the whole series recreates it as a repeating wish', async () => {
const wish = seriesWish(2028)
const remove = vi.fn().mockResolvedValue(undefined)
const add = vi.fn().mockResolvedValue(wish)
const sync = vi
.fn()
.mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ oldWishId: 'lw-1', newWishId: null, kind: 'removed' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, add }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
renderWithBand(wish, '2028-06-15')
fireEvent.click(screen.getByTestId('period-delete-all-years'))
const undoButton = await screen.findByTestId('calendar-toast-undo')
fireEvent.click(undoButton)
// Rebuilt as the repeating wish it was, spanning the years the series
// covered — not as the single clicked occurrence.
await waitFor(() =>
expect(add).toHaveBeenCalledWith(
expect.objectContaining({ recurrence: 'yearly', firstYear: 2028, lastYear: 2030 }),
),
)
})
it('undo after editing one year of a series restores that row without re-materialising', async () => {
const wish = seriesWish(2028)
const update = vi.fn().mockResolvedValue(wish)
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 }))
renderWithBand(wish, '2028-06-15')
fireEvent.click(screen.getByTestId('period-save'))
const undoButton = await screen.findByTestId('calendar-toast-undo')
fireEvent.click(undoButton)
await waitFor(() => expect(update).toHaveBeenCalledTimes(2))
const [, undoPatch, undoScope] = update.mock.calls[1]!
expect(undoScope).toBe('occurrence')
// The row was already part of a series, so its repeat-ness is restored
// but the series-level end year is left alone (the backend rejects it
// under this scope).
expect(undoPatch).toMatchObject({ recurrence: 'yearly' })
expect(undoPatch).not.toHaveProperty('lastYear')
})
it('"every year" removes the whole series in one call', async () => {
const wish = seriesWish(2028)
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 }))
renderWithBand(wish, '2028-06-15')
fireEvent.click(screen.getByTestId('period-delete-all-years'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'series'))
})
})
})
})