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() 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 }) =>
{children}
, })) 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 (
{/* §todo10/11 default path — the planner decides funding. */} {/* 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. */} {/* The "Advanced" escape hatch — pins an exact day type for whichever parent `selectedParentId` currently names (Calendar.tsx's lifted `advancedParentId`, updated live via `onParentChange`). */} {props.onDelete && ( )} {(props.excludedEntries ?? []).map((entry) => ( ))}
) }, })) vi.mock('@/components/calendar/PeriodPopover', () => ({ PeriodPopover: (props: PeriodPopoverProps) => (
{/* §todo9 — the three-way remove. */}
), })) 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) => (
), })) 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() expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() }) it('renders 7 weekday headers in Monday-first Swedish order', () => { render() 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() 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() 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() 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() 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() 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() expect(screen.getByTestId('calendar-help-button')).toBeInTheDocument() expect(document.querySelector('.calendar__legend')).toBeNull() }) it('handles next/prev month navigation', () => { render() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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('§vacation-swap-visibility — reallocation toast', () => { // `reallocate_vacation_by_value` (backend) swaps vacation onto better // dates 1-for-1 with föräldrapenning after funding — a pair of // 'retyped' changes for the same parent (vacation -> FP on one date, // FP -> vacation on another). This must surface as its own toast // fragment, naming the periods involved, not just get folded into the // generic "N days changed" count. const summerWish = makeLeaveWish({ id: 'lw-summer', name: 'Sommar', nameMessageKey: 'wishEditor.seasonSommar' }) const easterWish = makeLeaveWish({ id: 'lw-easter', name: 'Påsk', nameMessageKey: null }) const swapChanges = [ makePlanChange({ kind: 'retyped', date: '2028-07-10', parentPersonalDataUuid: 'person-1', oldDayType: 'vacation', newDayType: 'sjukpenning', oldWishId: 'lw-summer', newWishId: 'lw-summer', }), makePlanChange({ kind: 'retyped', date: '2028-04-14', parentPersonalDataUuid: 'person-1', oldDayType: 'sjukpenning', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter', }), ] it('names both periods when the planner moves vacation between them (Swedish)', async () => { const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: swapChanges })) vi.mocked(useLeaveWishes).mockReturnValue( makeLeaveWishesResult({ add, data: [created, summerWish, easterWish] }), ) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() fireEvent.click(screen.getByTestId('preset-click')) const toast = await screen.findByTestId('calendar-toast') expect(toast).toHaveTextContent('Sommar') expect(toast).toHaveTextContent('Påsk') expect(toast).toHaveTextContent('föräldrapenning') }) it('names both periods when the planner moves vacation between them (English)', async () => { Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined, }) try { const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: swapChanges })) vi.mocked(useLeaveWishes).mockReturnValue( makeLeaveWishesResult({ add, data: [created, summerWish, easterWish] }), ) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render( , ) fireEvent.click(screen.getByTestId('preset-click')) const toast = await screen.findByTestId('calendar-toast') // The preset's name is re-translated (English catalogue), the // free-typed "Påsk" stays literal — same rule as everywhere else // `resolveWishName` is used. expect(toast).toHaveTextContent('Summer') expect(toast).toHaveTextContent('Påsk') expect(toast).toHaveTextContent('parental leave') } finally { Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true }) vi.unstubAllGlobals() } }) it('does not show the swap toast for an unrelated retype', async () => { const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue( makePlanSyncResponse({ changes: [ makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'person-1', oldDayType: 'sjukpenning', newDayType: 'lagsta', oldWishId: 'lw-summer', newWishId: 'lw-summer', }), ], }), ) vi.mocked(useLeaveWishes).mockReturnValue( makeLeaveWishesResult({ add, data: [created, summerWish] }), ) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() fireEvent.click(screen.getByTestId('preset-click')) const toast = await screen.findByTestId('calendar-toast') expect(toast).not.toHaveTextContent('föräldrapenning') expect(toast).not.toHaveTextContent('Påsk') }) }) 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() 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()).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() 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() 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() 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( , ) 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() // 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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, 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() 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')) }) }) }) })