Redesign the setup flow and consolidate the CSS into one primitives layer

Onboarding asks explicitly for one or two parents and shows one card per
parent for balances; the balance form is per parent with inline
validation; the partner can be filled from the FK deduction or added
from it. Settings and Dashboard reuse the same form.

CSS: bare buttons carry no colour any more; buttons, cards, choice
cards, fields, alerts, badges, eyebrow and modal chrome are defined once
in primitives.css. Media queries live only in responsive.css. A style
guard test blocks hex, !important and stray media queries. Bilingual
fixes: calendar selects size by content, header no longer wraps. Vitest
is capped at four workers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent e4719cb9ae
commit ae6db7e16f
105 changed files with 5683 additions and 3690 deletions
+241 -324
View File
@@ -1,11 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
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,
useDayOverrides,
useShowWeekNumbers,
getLastLeaveType,
useLeaveWishes,
@@ -20,7 +20,6 @@ import {
makeWorkspaceContextValue,
makePlannedDaysResult,
makeCalendarDaysResult,
makeDayOverridesResult,
makeShowWeekNumbersTuple,
makePlannedLeaveDay,
makeLeaveWishesResult,
@@ -41,13 +40,22 @@ vi.mock('@/context', () => ({
useWorkspace: vi.fn(),
}))
// Calendar.tsx only reads `useSearchParams` from react-router-dom (no
// `useNavigate`/route matching), so the rest of the module can stay real —
// only the search params themselves need to be controllable per test.
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router-dom')>()
return { ...actual, useSearchParams: vi.fn() }
})
const mockSetSearchParams: SetURLSearchParams = vi.fn()
vi.mock('@/hooks', () => ({
usePlannedDays: vi.fn(),
useShowWeekNumbers: vi.fn(() => [false]),
getLastLeaveType: vi.fn(() => 'vacation'),
setLastLeaveType: vi.fn(),
useCalendarDays: vi.fn(),
useDayOverrides: vi.fn(),
useLeaveWishes: vi.fn(),
useWorkspaceMembers: vi.fn(),
usePlanSync: vi.fn(),
@@ -64,15 +72,21 @@ vi.mock('@/components/common/LoadingSpinner', () => ({
vi.mock('@/components/modals/PlannedDayModal', () => ({
// Exposes the props this test suite cares about as data attributes/buttons
// so tests can assert on Calendar.tsx's wiring without rendering the real
// modal (that component has its own exhaustive test suite).
PlannedDayModal: (props: PlannedDayModalProps) => (
// modal (that component has its own exhaustive test suite). Mirrors the
// real component's `if (!isOpen) return null` — Calendar.tsx can have a
// non-empty `selectedDates` (so `firstSelectedDate` is set, and this mock
// gets rendered) while `isModalOpen` is still false, e.g. right after
// seeding the selection from a `?date=` param without opening the modal.
PlannedDayModal: (props: PlannedDayModalProps) => {
if (!props.isOpen) return null
return (
<div
data-testid="modal"
data-default-day-type={props.defaultDayType ?? ''}
data-existing-day={props.existingDay ? 'yes' : 'no'}
data-parents-count={props.parents.length}
data-day-info={props.dayInfo ? 'yes' : 'no'}
data-override-error={props.overrideError ?? ''}
data-is-range-mode={props.isRangeMode ? 'yes' : 'no'}
data-day-count={props.dayCount ?? ''}
>
<button
data-testid="modal-save"
@@ -85,16 +99,12 @@ vi.mock('@/components/modals/PlannedDayModal', () => ({
delete
</button>
)}
{props.onToggleOverride && (
<button data-testid="modal-toggle-override" onClick={() => void props.onToggleOverride?.()}>
toggle
</button>
)}
<button data-testid="modal-close" onClick={props.onClose}>
close
</button>
</div>
),
)
},
}))
vi.mock('@/components/calendar/PeriodPopover', () => ({
@@ -164,19 +174,18 @@ describe('Calendar', () => {
const mockPlannedDays = makePlannedDaysResult()
const mockCalendarWindow = makeCalendarDaysResult()
const mockDayOverrides = makeDayOverridesResult()
const mockLeaveWishes = makeLeaveWishesResult()
const mockMembers = makeWorkspaceMembersResult()
const mockSync = makePlanSyncResult()
beforeEach(() => {
vi.mocked(useCalendarDays).mockReturnValue(mockCalendarWindow)
vi.mocked(useDayOverrides).mockReturnValue(mockDayOverrides)
vi.mocked(useLeaveWishes).mockReturnValue(mockLeaveWishes)
vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers)
vi.mocked(usePlanSync).mockReturnValue(mockSync)
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams])
})
function todayDateString(): string {
@@ -212,6 +221,22 @@ describe('Calendar', () => {
expect(calendarDays.length % 7).toBe(0)
})
it('does not render a phantom trailing week when the month ends exactly on a Sunday', () => {
// May 2026 has 31 days, starting on a Friday: 4 leading days + 31 = 35,
// an exact multiple of 7 — the naive "always pad and push a trailing
// week" logic would add a 6th row of 7 disabled June cells that serves
// no purpose.
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
fireEvent.change(yearSelect, { target: { value: '2026' } })
fireEvent.change(monthSelect, { target: { value: '4' } })
const calendarDays = screen.getAllByTestId('calendar-day')
expect(calendarDays.length).toBe(35)
})
it('opens the day popover when clicking a current-month day', () => {
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
@@ -223,7 +248,12 @@ describe('Calendar', () => {
}
})
it('does not open the day popover when clicking disabled (non-current-month) day', () => {
it('opens the day popover when clicking a disabled (adjacent-month) day — planning a visible day outside the displayed month is deliberate, not dead', () => {
// Decided behaviour for the drag-across-month-boundary fix: a leading/
// trailing cell stays dimmed (`calendar-day--disabled` — see
// CalendarDay.tsx) but is still clickable/drag-selectable, same as any
// other visible day. Silently doing nothing on a visible cell was the
// original bug (see CalendarWeek.tsx's day-cell handlers).
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const disabledDay = calendarDays.find((el) => el.classList.contains('calendar-day--disabled'))
@@ -231,6 +261,7 @@ describe('Calendar', () => {
if (disabledDay) {
fireEvent.click(disabledDay)
expect(disabledDay).toHaveClass('calendar-day--disabled')
expect(screen.getByTestId('modal')).toBeInTheDocument()
}
})
@@ -297,6 +328,59 @@ describe('Calendar', () => {
expect(options).toContain(new Date().getFullYear())
})
describe('?date= query param seeds the initial month/selection', () => {
it('opens the month of a valid future ?date= and highlights that day, without opening the modal', () => {
vi.mocked(useSearchParams).mockReturnValue([
new URLSearchParams('date=2030-07-15'),
mockSetSearchParams,
])
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
expect(yearSelect.value).toBe('2030')
expect(monthSelect.value).toBe('6') // July is month index 6
const targetDay = screen
.getAllByTestId('calendar-day')
.find((el) => el.getAttribute('aria-label')?.startsWith('2030-07-15'))
expect(targetDay).toHaveClass('calendar-day--selected')
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
it('falls back to today for an invalid/garbage ?date= param', () => {
vi.mocked(useSearchParams).mockReturnValue([
new URLSearchParams('date=not-a-real-date'),
mockSetSearchParams,
])
render(<Calendar />)
const today = new Date()
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
expect(yearSelect.value).toBe(String(today.getFullYear()))
expect(monthSelect.value).toBe(String(today.getMonth()))
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
it('falls back to today when no ?date= param is present', () => {
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams])
render(<Calendar />)
const today = new Date()
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') 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()
@@ -320,90 +404,6 @@ describe('Calendar', () => {
expect(screen.queryByTestId('calendar-override-toggle')).not.toBeInTheDocument()
expect(screen.queryByTestId('calendar-override-hint')).not.toBeInTheDocument()
})
it('passes the clicked date\'s calendar metadata as dayInfo to the day popover', () => {
const today = todayDateString()
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, isFree: true })] }),
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
expect(screen.getByTestId('modal')).toHaveAttribute('data-day-info', 'yes')
})
it('toggling the day popover\'s override switch marks a free day as a working day', async () => {
const today = todayDateString()
const upsert = vi.fn().mockResolvedValue(undefined)
const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined)
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }),
refetch: refetchCalendarWindow,
}),
)
vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() =>
expect(upsert).toHaveBeenCalledWith({ workspaceId: 'ws-1', date: today, isWorkingDay: true, note: null }),
)
await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalled())
})
it('removes an existing override when toggled again', async () => {
const today = todayDateString()
const removeOverride = vi.fn().mockResolvedValue(undefined)
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({
days: [makeCalendarDayEntry({ date: today, isOverride: true, overrideIsWorkingDay: false, isFree: true })],
}),
}),
)
vi.mocked(useDayOverrides).mockReturnValue(
makeDayOverridesResult({
data: [{ id: 'ov-1', workspaceId: 'ws-1', date: today, isWorkingDay: false, note: null, createdAt: null, updatedAt: null }],
upsert: vi.fn(),
remove: removeOverride,
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() => expect(removeOverride).toHaveBeenCalledWith('ov-1'))
})
it('surfaces an override failure through the modal\'s overrideError prop', async () => {
const today = todayDateString()
const upsert = vi.fn().mockRejectedValue(new Error('quota exceeded'))
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }),
}),
)
vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() => expect(screen.getByTestId('modal')).toHaveAttribute('data-override-error', 'quota exceeded'))
})
})
describe('§Task7 saving a day pins it', () => {
@@ -500,15 +500,31 @@ describe('Calendar', () => {
fireEvent.click(currentMonthDay!)
expect(screen.getByTestId('modal')).toHaveAttribute('data-parents-count', '1')
})
it('labels a partner-owned band with their actual name, not the generic "Partner" string', () => {
const today = todayDateString()
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Sam' })],
}),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'pd-b' })],
}),
)
render(<Calendar />)
expect(screen.getByText(/Sam ·/)).toBeInTheDocument()
expect(screen.queryByText(/^Partner ·/)).not.toBeInTheDocument()
})
})
describe('§Task7 drag-select creates a period', () => {
it('creates a fixed, once wish covering the dragged range and syncs', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
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: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
@@ -520,21 +536,16 @@ describe('Calendar', () => {
fireEvent.pointerEnter(endDay)
fireEvent.pointerUp(window)
await waitFor(() =>
expect(add).toHaveBeenCalledWith(
expect.objectContaining({
recurrence: 'once',
windowMode: 'fixed',
parentMode: 'specific',
parentPersonalDataUuid: 'person-1',
}),
),
)
// No day popover opens for a multi-day drag — the period is created directly.
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
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('does not open the day popover for a single-day drag (falls back to the normal click path)', () => {
it('opens the day popover in single mode for a single-day drag (falls back to the normal click path)', () => {
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
@@ -542,21 +553,33 @@ describe('Calendar', () => {
fireEvent.pointerDown(currentMonthDay)
fireEvent.pointerUp(window)
expect(screen.getByTestId('modal')).toBeInTheDocument()
const modal = screen.getByTestId('modal')
expect(modal).toBeInTheDocument()
expect(modal).toHaveAttribute('data-is-range-mode', 'no')
})
it('shows an error toast when creating the period fails', async () => {
const add = vi.fn().mockRejectedValue(new Error('validation failed'))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
it('a leading week\'s in-month days are draggable (regression: the week used to be gated by weekDates[0]\'s month, not the displayed month)', () => {
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') 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' } })
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('validation failed'))
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()
})
})
@@ -773,46 +796,42 @@ describe('Calendar', () => {
})
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-new', name: 'Sommar' })
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' }), makePlanChange({ newWishId: 'lw-new' })] }),
makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' }), makePlanChange({ newWishId: 'lw-preset' })] }),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
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-new' })
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
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-new' })
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
@@ -820,7 +839,7 @@ describe('Calendar', () => {
proposal: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-new',
wishId: 'lw-preset',
startDate: '2026-06-01',
endDate: '2026-06-14',
weeksWished: 2,
@@ -836,34 +855,34 @@ describe('Calendar', () => {
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent('Sommar')
expect(toast).toHaveTextContent('10')
})
it('reports a calendar-drawn period\'s shortfall in days, not weeks, since it carries an exact targetDays', async () => {
// Regression: a drag-created wish sets `targetDays` (an exact day
// count); `targetWeeks` on that same wish is just a rough
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.
const created = makeLeaveWish({ id: 'lw-new', name: 'Ny period', targetDays: 7, targetWeeks: 1 })
const add = vi.fn().mockResolvedValue(created)
// 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-new',
wishId: 'lw-1',
startDate: '2026-09-17',
endDate: '2026-09-23',
weeksWished: 3,
@@ -875,15 +894,17 @@ describe('Calendar', () => {
}),
}),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
}),
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[6] as HTMLElement)
fireEvent.pointerUp(window)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-save'))
const toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent('7')
@@ -893,7 +914,7 @@ describe('Calendar', () => {
})
it('shows no toast when a sync with no changes also has no outcome for the touched wish (genuine no-op)', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
@@ -905,26 +926,22 @@ describe('Calendar', () => {
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
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-new', name: 'Sommar' })
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [makePlanChange({ newWishId: 'lw-new' })],
changes: [makePlanChange({ newWishId: 'lw-preset' })],
proposal: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-new',
wishId: 'lw-preset',
startDate: '2026-06-01',
endDate: '2026-06-14',
weeksWished: 2,
@@ -940,11 +957,7 @@ describe('Calendar', () => {
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
// What changed (existing "N day(s) changed" summary) ...
@@ -955,7 +968,7 @@ describe('Calendar', () => {
})
it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
@@ -978,11 +991,7 @@ describe('Calendar', () => {
)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent('Förälder A')
@@ -1026,56 +1035,20 @@ describe('Calendar', () => {
})
it('dismisses the toast on demand', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' })] }))
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
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 creating a period from a drag deletes the wish and re-syncs, leaving day cleanup to the backend', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const add = vi.fn().mockResolvedValue(created)
const remove = vi.fn().mockResolvedValue(undefined)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, remove, data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
// `LeaveWishService.delete()` deletes every `planned_leave_day` row
// the wish produced as part of deleting the wish itself — the
// frontend no longer snapshots or deletes days individually. The
// outcome under test is therefore the delegation: undo removes the
// wish and re-syncs, and never touches `plannedDaysHook.remove` at all.
const removeDay = vi.fn().mockResolvedValue(undefined)
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ remove: removeDay }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
const undoButton = await screen.findByTestId('calendar-toast-undo')
const syncCallsBeforeUndo = sync.mock.calls.length
fireEvent.click(undoButton)
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-new'))
await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo))
expect(removeDay).not.toHaveBeenCalled()
})
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)
@@ -1179,7 +1152,6 @@ describe('Calendar', () => {
expect(usePlannedDays).toHaveBeenCalledWith(undefined)
expect(useCalendarDays).toHaveBeenCalledWith('', expect.any(Number), expect.any(Number))
expect(useDayOverrides).toHaveBeenCalledWith('')
})
it('treats a missing calendar window as having no days yet', () => {
@@ -1277,70 +1249,6 @@ describe('Calendar', () => {
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
it('shows a generic override error when the mutation rejects with a non-Error value', async () => {
const today = todayDateString()
const upsert = vi.fn().mockRejectedValue('network exploded')
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }),
}),
)
vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() => {
const attr = screen.getByTestId('modal').getAttribute('data-override-error')
expect(attr).not.toBe('')
expect(attr).not.toBe('network exploded')
})
})
it('treats a missing day-overrides list as empty when clearing an override', async () => {
const today = todayDateString()
const removeOverride = vi.fn()
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({
days: [makeCalendarDayEntry({ date: today, isOverride: true, overrideIsWorkingDay: false, isFree: true })],
}),
}),
)
// `data` is undefined here (e.g. before the overrides fetch resolves) —
// the `(dayOverridesHook.data ?? []).find(...)` fallback must kick in.
vi.mocked(useDayOverrides).mockReturnValue({
...makeDayOverridesResult({ isLoading: true, upsert: vi.fn(), remove: removeOverride }),
data: undefined,
} as unknown as ReturnType<typeof useDayOverrides>)
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
await waitFor(() => expect(screen.getByTestId('modal')).toHaveAttribute('data-override-error', ''))
expect(removeOverride).not.toHaveBeenCalled()
})
it('does not attempt an override toggle when the workspace id is missing', () => {
const upsert = vi.fn()
vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ workspace: null, personalDataUuid: 'person-1' }))
vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.click(currentMonthDay!)
fireEvent.click(screen.getByTestId('modal-toggle-override'))
expect(upsert).not.toHaveBeenCalled()
})
it('shows a generic error toast for a non-Error rejection while syncing', async () => {
const sync = vi.fn().mockRejectedValue('boom')
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
@@ -1353,37 +1261,6 @@ describe('Calendar', () => {
})
})
it('shows a generic error toast for a non-Error rejection when creating a period from a drag', async () => {
const add = vi.fn().mockRejectedValue('boom')
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
await waitFor(() =>
expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Det gick inte att skapa perioden'),
)
})
it('does not create a period from a drag when the workspace id is missing', () => {
const add = vi.fn()
vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ workspace: null, personalDataUuid: null }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
expect(add).not.toHaveBeenCalled()
})
it('shows a generic error toast for a non-Error rejection on undo', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
@@ -1535,7 +1412,7 @@ describe('Calendar', () => {
})
it('summarizes a change for a wish not present in the local wish list as a generic period', async () => {
const created = makeLeaveWish({ id: 'lw-new' })
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'some-other-wish-id' })] }),
@@ -1545,13 +1422,53 @@ describe('Calendar', () => {
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
const calendarDays = screen.getAllByTestId('calendar-day')
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
fireEvent.pointerDown(currentMonthDays[0] as HTMLElement)
fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement)
fireEvent.pointerUp(window)
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Perioden'))
})
})
describe('§Task7 drag-select range save writes every dragged day', () => {
it('writes every dragged date as its own planned day (add for new dates, update for existing ones), all pinned', async () => {
// Render once first to read off the actual dates the drag will cover
// (independent of which day of the month the suite happens to run
// on), then re-render with a pre-existing planned day on the first
// of those dates — the save must update it in place, not skip or
// duplicate it.
const { unmount } = render(<Calendar />)
const probeDays = screen.getAllByTestId('calendar-day').filter((el) => !el.classList.contains('calendar-day--disabled'))
const firstDate = probeDays[0]!.getAttribute('aria-label')!.slice(0, 10)
unmount()
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
const update = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-existing', date: firstDate, parentPersonalDataUuid: 'person-1' })],
add,
update,
}),
)
render(<Calendar />)
const currentMonthDays = screen.getAllByTestId('calendar-day').filter((el) => !el.classList.contains('calendar-day--disabled'))
const startDay = currentMonthDays[0] as HTMLElement
const endDay = currentMonthDays[2] as HTMLElement
fireEvent.pointerDown(startDay)
fireEvent.pointerEnter(endDay)
fireEvent.pointerUp(window)
const modal = await screen.findByTestId('modal')
expect(modal).toHaveAttribute('data-day-count', '3')
fireEvent.click(screen.getByTestId('modal-save'))
// 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())
})
})
})