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:
2026-09-14 02:33:03 +02:00
parent 3ca1418d94
commit 76b1e712b9
218 changed files with 29867 additions and 6085 deletions
+316 -3
View File
@@ -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')
})
})
})