Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { Calendar } from './Calendar'
|
||||
import { useWorkspace } from '@/context'
|
||||
import { usePlannedDays } from '@/hooks'
|
||||
import { usePlannedDays, useCalendarDays, useDayOverrides } from '@/hooks'
|
||||
|
||||
vi.mock('@/context', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
@@ -13,6 +13,8 @@ vi.mock('@/hooks', () => ({
|
||||
useShowWeekNumbers: vi.fn(() => [false]),
|
||||
getLastLeaveType: vi.fn(() => 'vacation'),
|
||||
setLastLeaveType: vi.fn(),
|
||||
useCalendarDays: vi.fn(),
|
||||
useDayOverrides: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/Layout', () => ({
|
||||
@@ -47,6 +49,25 @@ describe('Calendar', () => {
|
||||
remove: vi.fn(),
|
||||
}
|
||||
|
||||
const mockCalendarWindow = {
|
||||
data: { startYear: 2026, endYear: 2026, minYear: 2026, maxYear: 2035, days: [] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
const mockDayOverrides = {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
upsert: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
;(useCalendarDays as any).mockReturnValue(mockCalendarWindow)
|
||||
;(useDayOverrides as any).mockReturnValue(mockDayOverrides)
|
||||
})
|
||||
|
||||
it('renders loading state while data is loading', () => {
|
||||
;(useWorkspace as any).mockReturnValue({ ...mockWorkspace, isLoading: true })
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
@@ -138,10 +159,10 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const nextButton = screen.getByLabelText('Next month')
|
||||
const nextButton = screen.getByLabelText('Nästa månad')
|
||||
fireEvent.click(nextButton)
|
||||
|
||||
const prevButton = screen.getByLabelText('Previous month')
|
||||
const prevButton = screen.getByLabelText('Föregående månad')
|
||||
fireEvent.click(prevButton)
|
||||
|
||||
expect(screen.getByTestId('calendar')).toBeInTheDocument()
|
||||
@@ -174,4 +195,296 @@ describe('Calendar', () => {
|
||||
|
||||
expect(selectedDaysInRange.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('advances one month when clicking the month button', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthButton = title.querySelector('.calendar__month-button') as HTMLButtonElement
|
||||
|
||||
expect(monthButton).toBeInTheDocument()
|
||||
|
||||
// Get initial month/year from title
|
||||
const initialText = title.textContent
|
||||
|
||||
// Click the month button to advance one month
|
||||
fireEvent.click(monthButton)
|
||||
|
||||
// The month should have changed (we can't predict the exact month without knowing current date,
|
||||
// but we can verify the text changed)
|
||||
const updatedText = title.textContent
|
||||
expect(updatedText).not.toBe(initialText)
|
||||
})
|
||||
|
||||
it('advances one year when clicking the year button', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearButton = title.querySelector('.calendar__year-button') as HTMLButtonElement
|
||||
|
||||
expect(yearButton).toBeInTheDocument()
|
||||
|
||||
// Get initial year
|
||||
const initialYear = parseInt(yearButton.textContent || '0')
|
||||
|
||||
// Click the year button to advance one year
|
||||
fireEvent.click(yearButton)
|
||||
|
||||
// The year should have increased by 1
|
||||
const updatedYearButton = screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-button') as HTMLButtonElement
|
||||
const updatedYear = parseInt(updatedYearButton.textContent || '0')
|
||||
expect(updatedYear).toBe(initialYear + 1)
|
||||
})
|
||||
|
||||
it('handles December to January rollover when clicking month button in December', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
// Mock a December render - we need to test the logic, not the actual calendar state
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthButton = title.querySelector('.calendar__month-button') as HTMLButtonElement
|
||||
|
||||
// Click month button 12 times (a full year cycle)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
fireEvent.click(monthButton)
|
||||
}
|
||||
|
||||
// The year might have incremented depending on when we started, but the calendar should still work
|
||||
const finalTitle = screen.getByRole('heading', { level: 2 })
|
||||
expect(finalTitle).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('month and year buttons have proper accessibility labels', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthButton = title.querySelector('.calendar__month-button') as HTMLButtonElement
|
||||
const yearButton = title.querySelector('.calendar__year-button') as HTMLButtonElement
|
||||
|
||||
expect(monthButton).toHaveAttribute('aria-label')
|
||||
expect(yearButton).toHaveAttribute('aria-label')
|
||||
expect(monthButton.getAttribute('aria-label')).toContain('klicka för att byta månad')
|
||||
expect(yearButton.getAttribute('aria-label')).toContain('klicka för att byta år')
|
||||
})
|
||||
|
||||
// §3.4 shading + §3.6 click-to-override
|
||||
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}`
|
||||
}
|
||||
|
||||
describe('§3.4 red day / klämdag shading', () => {
|
||||
it('shades a statutory red day and shows its name', () => {
|
||||
const today = todayDateString()
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
;(useCalendarDays as any).mockReturnValue({
|
||||
data: {
|
||||
startYear: 2026,
|
||||
endYear: 2026,
|
||||
minYear: 2026,
|
||||
maxYear: 2035,
|
||||
days: [
|
||||
{
|
||||
date: today,
|
||||
name: 'Påskdagen; Söndag',
|
||||
dayType: 'red_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const redDay = document.querySelector('.calendar-day--red-day')
|
||||
expect(redDay).toBeInTheDocument()
|
||||
// The combined "X; Söndag" form is not dumped raw — the weekday suffix is stripped.
|
||||
expect(redDay).toHaveAttribute('title', 'Påskdagen')
|
||||
expect(redDay).toHaveAttribute('aria-label', expect.stringContaining('Påskdagen'))
|
||||
})
|
||||
|
||||
it('marks an overridden day distinctly from a plain statutory day', () => {
|
||||
const today = todayDateString()
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
;(useCalendarDays as any).mockReturnValue({
|
||||
data: {
|
||||
startYear: 2026,
|
||||
endYear: 2026,
|
||||
minYear: 2026,
|
||||
maxYear: 2035,
|
||||
days: [
|
||||
{
|
||||
date: today,
|
||||
name: null,
|
||||
dayType: 'working_day',
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: false,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const overridden = document.querySelector('.calendar-day--override')
|
||||
expect(overridden).toBeInTheDocument()
|
||||
expect(screen.getByTestId('calendar-day-override-badge')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('§3.6 click-to-override', () => {
|
||||
it('shows an override-mode toggle button', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
expect(screen.getByTestId('calendar-override-toggle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking a day while override mode is active does not open the planning modal', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('calendar-override-toggle'))
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
expect(currentMonthDay).toBeDefined()
|
||||
fireEvent.click(currentMonthDay!)
|
||||
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggling override mode off restores normal click-to-plan behaviour', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const toggle = screen.getByTestId('calendar-override-toggle')
|
||||
fireEvent.click(toggle) // on
|
||||
fireEvent.click(toggle) // off
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('clicking a currently-free day in override mode marks it as a working day', async () => {
|
||||
const today = todayDateString()
|
||||
const upsert = vi.fn().mockResolvedValue(undefined)
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
;(useCalendarDays as any).mockReturnValue({
|
||||
data: {
|
||||
startYear: 2026,
|
||||
endYear: 2026,
|
||||
minYear: 2026,
|
||||
maxYear: 2035,
|
||||
days: [
|
||||
{
|
||||
date: today,
|
||||
name: null,
|
||||
dayType: 'weekend',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
;(useDayOverrides as any).mockReturnValue({ data: [], isLoading: false, error: null, upsert, remove: vi.fn() })
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('calendar-override-toggle'))
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
|
||||
expect(todayCell).toBeDefined()
|
||||
fireEvent.click(todayCell!)
|
||||
|
||||
await screen.findByTestId('calendar-override-toggle')
|
||||
expect(upsert).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
date: today,
|
||||
isWorkingDay: true,
|
||||
note: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('clicking an already-overridden day in override mode removes the override', async () => {
|
||||
const today = todayDateString()
|
||||
const removeOverride = vi.fn().mockResolvedValue(undefined)
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
;(useCalendarDays as any).mockReturnValue({
|
||||
data: {
|
||||
startYear: 2026,
|
||||
endYear: 2026,
|
||||
minYear: 2026,
|
||||
maxYear: 2035,
|
||||
days: [
|
||||
{
|
||||
date: today,
|
||||
name: null,
|
||||
dayType: 'working_day',
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: false,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
;(useDayOverrides as any).mockReturnValue({
|
||||
data: [{ id: 'ov-1', workspaceId: 'ws-1', date: today, isWorkingDay: false, note: null }],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
upsert: vi.fn(),
|
||||
remove: removeOverride,
|
||||
})
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('calendar-override-toggle'))
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
|
||||
expect(todayCell).toBeDefined()
|
||||
fireEvent.click(todayCell!)
|
||||
|
||||
await screen.findByTestId('calendar-override-toggle')
|
||||
expect(removeOverride).toHaveBeenCalledWith('ov-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user