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:
@@ -1,8 +1,14 @@
|
||||
export { useAnalytics } from './useAnalytics'
|
||||
export { useBreakPeriods } from './useBreakPeriods'
|
||||
export { useLeaveWishes } from './useLeaveWishes'
|
||||
export { usePlanProposal } from './usePlanProposal'
|
||||
export { usePlanApply } from './usePlanApply'
|
||||
export { useWorkspaceMembers } from './useWorkspaceMembers'
|
||||
export { useShowWeekNumbers, getLastLeaveType, setLastLeaveType } from './useCalendarPreferences'
|
||||
export { useChildSettings } from './useChildSettings'
|
||||
export { useOptimizationTips } from './useOptimizationTips'
|
||||
export { useParentalLeave } from './useParentalLeave'
|
||||
export { usePlannedDays } from './usePlannedDays'
|
||||
export { useVacationDays } from './useVacationDays'
|
||||
export { useDayOverrides } from './useDayOverrides'
|
||||
export { useCalendarDays } from './useCalendarDays'
|
||||
export { useScraperFetch } from './useScraperFetch'
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useBreakPeriods } from './useBreakPeriods'
|
||||
import * as api from '@/services/api'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
describe('useBreakPeriods', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('initializes with null data', () => {
|
||||
const { result } = renderHook(() => useBreakPeriods(null))
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('fetches break periods on mount', async () => {
|
||||
const mockData = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws1',
|
||||
name: 'Summer',
|
||||
startDate: '2026-06-01',
|
||||
endDate: '2026-08-31',
|
||||
targetWeeks: 8,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(api.getBreakPeriods).mockResolvedValue(mockData as any)
|
||||
|
||||
renderHook(() => useBreakPeriods('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(api.getBreakPeriods).toHaveBeenCalledWith('ws1')
|
||||
})
|
||||
|
||||
it('handles errors during fetch', async () => {
|
||||
vi.mocked(api.getBreakPeriods).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() => useBreakPeriods('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toContain('Network error')
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('adds break period and refreshes', async () => {
|
||||
const mockAdded = {
|
||||
id: '2',
|
||||
workspace_id: 'ws1',
|
||||
name: 'Winter',
|
||||
start_date: '2026-12-01',
|
||||
end_date: '2026-12-31',
|
||||
target_weeks: 2,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
vi.mocked(api.createBreakPeriod).mockResolvedValue(mockAdded as any)
|
||||
vi.mocked(api.getBreakPeriods).mockResolvedValue([mockAdded] as any)
|
||||
|
||||
const { result } = renderHook(() => useBreakPeriods('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.add('2026-12-01', '2026-12-31', 'Winter', 2)
|
||||
})
|
||||
|
||||
expect(api.createBreakPeriod).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes break period and refreshes', async () => {
|
||||
vi.mocked(api.deleteBreakPeriod).mockResolvedValue(undefined)
|
||||
vi.mocked(api.getBreakPeriods).mockResolvedValue([])
|
||||
|
||||
const { result } = renderHook(() => useBreakPeriods('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.remove('bp1')
|
||||
})
|
||||
|
||||
expect(api.deleteBreakPeriod).toHaveBeenCalledWith('bp1')
|
||||
})
|
||||
|
||||
it('does not fetch when workspaceId is null', () => {
|
||||
vi.mocked(api.getBreakPeriods).mockResolvedValue([])
|
||||
|
||||
renderHook(() => useBreakPeriods(null))
|
||||
|
||||
expect(api.getBreakPeriods).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import type { BreakPeriod } from '@/types'
|
||||
|
||||
interface UseBreakPeriodsResult {
|
||||
data: BreakPeriod[] | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
add: (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
name: string,
|
||||
targetWeeks: number
|
||||
) => Promise<BreakPeriod>
|
||||
remove: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useBreakPeriods(workspaceId: string | null): UseBreakPeriodsResult {
|
||||
const [data, setData] = useState<BreakPeriod[] | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await api.getBreakPeriods(workspaceId)
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch break periods')
|
||||
setData(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
const add = useCallback(
|
||||
async (startDate: string, endDate: string, name: string, targetWeeks: number) => {
|
||||
if (!workspaceId) throw new Error('Workspace not selected')
|
||||
const result = await api.createBreakPeriod(workspaceId, {
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
targetWeeks,
|
||||
})
|
||||
await fetch()
|
||||
return result
|
||||
},
|
||||
[workspaceId, fetch]
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await api.deleteBreakPeriod(id)
|
||||
await fetch()
|
||||
},
|
||||
[fetch]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetch()
|
||||
}, [fetch])
|
||||
|
||||
return { data, isLoading, error, refresh: fetch, add, remove }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useCalendarDays } from './useCalendarDays'
|
||||
import * as api from '@/services/api'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
describe('useCalendarDays', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const mockWindow = {
|
||||
startYear: 2026,
|
||||
endYear: 2029,
|
||||
minYear: 2026,
|
||||
maxYear: 2035,
|
||||
days: [
|
||||
{
|
||||
date: '2026-01-01',
|
||||
name: 'Nyårsdagen',
|
||||
dayType: 'red_day' as const,
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('does not fetch when workspaceId is empty', () => {
|
||||
renderHook(() => useCalendarDays('', 2026, 4))
|
||||
expect(api.getCalendarDays).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches the window for the given workspace/startYear/years', async () => {
|
||||
vi.mocked(api.getCalendarDays).mockResolvedValue(mockWindow)
|
||||
|
||||
const { result } = renderHook(() => useCalendarDays('ws-1', 2026, 4))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(api.getCalendarDays).toHaveBeenCalledWith('ws-1', 2026, 4)
|
||||
expect(result.current.data).toEqual(mockWindow)
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('refetches when startYear changes', async () => {
|
||||
vi.mocked(api.getCalendarDays).mockResolvedValue(mockWindow)
|
||||
|
||||
const { rerender } = renderHook(({ startYear }: { startYear: number }) => useCalendarDays('ws-1', startYear, 4), {
|
||||
initialProps: { startYear: 2026 },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
rerender({ startYear: 2030 })
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(api.getCalendarDays).toHaveBeenCalledWith('ws-1', 2026, 4)
|
||||
expect(api.getCalendarDays).toHaveBeenCalledWith('ws-1', 2030, 4)
|
||||
})
|
||||
|
||||
it('sets an error message when the fetch fails', async () => {
|
||||
vi.mocked(api.getCalendarDays).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() => useCalendarDays('ws-1', 2026, 4))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Network error')
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a generic error message for a non-Error rejection', async () => {
|
||||
vi.mocked(api.getCalendarDays).mockRejectedValue('boom')
|
||||
|
||||
const { result } = renderHook(() => useCalendarDays('ws-1', 2026, 4))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Failed to load calendar days')
|
||||
})
|
||||
|
||||
it('reports isLoading true while the request is in flight', async () => {
|
||||
let resolveFetch: (value: typeof mockWindow) => void
|
||||
const pending = new Promise<typeof mockWindow>((resolve) => {
|
||||
resolveFetch = resolve
|
||||
})
|
||||
vi.mocked(api.getCalendarDays).mockReturnValue(pending)
|
||||
|
||||
const { result } = renderHook(() => useCalendarDays('ws-1', 2026, 4))
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch!(mockWindow)
|
||||
await pending
|
||||
})
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Fetches a `years`-year window of Swedish calendar metadata (röda dagar,
|
||||
* klämdagar, weekends, workspace day overrides) starting at `startYear`.
|
||||
*
|
||||
* Backs both the Kalender page's shading + click-to-override (§3.4/§3.6) and
|
||||
* the Settings red-days list (§3.8) from the same `GET /api/calendar/days`
|
||||
* endpoint — see `app/routers/calendar.py` / ARCHITECTURE.md §13.3.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import type { CalendarWindow } from '@/types'
|
||||
|
||||
interface UseCalendarDaysResult {
|
||||
data: CalendarWindow | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useCalendarDays(
|
||||
workspaceId: string,
|
||||
startYear: number,
|
||||
years: number = 4,
|
||||
): UseCalendarDaysResult {
|
||||
const [data, setData] = useState<CalendarWindow | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchWindow = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.getCalendarDays(workspaceId, startYear, years)
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load calendar days')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId, startYear, years])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchWindow()
|
||||
}, [fetchWindow])
|
||||
|
||||
return { data, isLoading, error }
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import {
|
||||
useShowWeekNumbers,
|
||||
getLastLeaveType,
|
||||
setLastLeaveType,
|
||||
} from './useCalendarPreferences'
|
||||
|
||||
const SHOW_WEEK_NUMBERS_KEY = 'calendar_show_week_numbers'
|
||||
const LAST_LEAVE_TYPE_KEY = 'calendar_last_leave_type'
|
||||
|
||||
// Node's own experimental `localStorage`/`sessionStorage` globals (active in
|
||||
// newer Node runtimes without a valid --localstorage-file) shadow jsdom's
|
||||
// working implementation and throw on every call. Stub a real in-memory
|
||||
// Storage for these tests, matching the idiom used in WorkspaceContext.test.tsx.
|
||||
function createStorageMock(): Storage {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => (key in store ? store[key]! : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key]
|
||||
},
|
||||
clear: () => {
|
||||
store = {}
|
||||
},
|
||||
key: (index: number) => Object.keys(store)[index] ?? null,
|
||||
get length() {
|
||||
return Object.keys(store).length
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('useShowWeekNumbers', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createStorageMock())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('defaults to false when nothing is stored', () => {
|
||||
const { result } = renderHook(() => useShowWeekNumbers())
|
||||
expect(result.current[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the persisted value on mount', () => {
|
||||
localStorage.setItem(SHOW_WEEK_NUMBERS_KEY, 'true')
|
||||
const { result } = renderHook(() => useShowWeekNumbers())
|
||||
expect(result.current[0]).toBe(true)
|
||||
})
|
||||
|
||||
it('treats any non-"true" stored value as false', () => {
|
||||
localStorage.setItem(SHOW_WEEK_NUMBERS_KEY, 'garbage')
|
||||
const { result } = renderHook(() => useShowWeekNumbers())
|
||||
expect(result.current[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('persists the new value and updates state when the setter is called', () => {
|
||||
const { result } = renderHook(() => useShowWeekNumbers())
|
||||
|
||||
act(() => {
|
||||
result.current[1](true)
|
||||
})
|
||||
|
||||
expect(localStorage.getItem(SHOW_WEEK_NUMBERS_KEY)).toBe('true')
|
||||
expect(result.current[0]).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current[1](false)
|
||||
})
|
||||
|
||||
expect(localStorage.getItem(SHOW_WEEK_NUMBERS_KEY)).toBe('false')
|
||||
expect(result.current[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('syncs across instances via the custom preferences-changed event', () => {
|
||||
const { result: a } = renderHook(() => useShowWeekNumbers())
|
||||
const { result: b } = renderHook(() => useShowWeekNumbers())
|
||||
|
||||
act(() => {
|
||||
a.current[1](true)
|
||||
})
|
||||
|
||||
expect(b.current[0]).toBe(true)
|
||||
})
|
||||
|
||||
it('syncs across tabs via the native storage event', () => {
|
||||
const { result } = renderHook(() => useShowWeekNumbers())
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', { key: SHOW_WEEK_NUMBERS_KEY, newValue: 'true' }),
|
||||
)
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores storage events for unrelated keys', () => {
|
||||
const { result } = renderHook(() => useShowWeekNumbers())
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', { key: 'some_other_key', newValue: 'true' }),
|
||||
)
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('removes its listeners on unmount', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const { unmount } = renderHook(() => useShowWeekNumbers())
|
||||
|
||||
unmount()
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('storage', expect.any(Function))
|
||||
expect(removeSpy).toHaveBeenCalledWith('calendar:preferences-changed', expect.any(Function))
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLastLeaveType / setLastLeaveType', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('sessionStorage', createStorageMock())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(getLastLeaveType()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the stored value is not a valid leave type', () => {
|
||||
sessionStorage.setItem(LAST_LEAVE_TYPE_KEY, 'not-a-real-type')
|
||||
expect(getLastLeaveType()).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips a valid leave type', () => {
|
||||
setLastLeaveType('vacation')
|
||||
expect(sessionStorage.getItem(LAST_LEAVE_TYPE_KEY)).toBe('vacation')
|
||||
expect(getLastLeaveType()).toBe('vacation')
|
||||
})
|
||||
|
||||
it('accepts every valid leave type', () => {
|
||||
for (const type of ['sjukpenning', 'lagsta', 'vacation'] as const) {
|
||||
setLastLeaveType(type)
|
||||
expect(getLastLeaveType()).toBe(type)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useChildSettings } from './useChildSettings'
|
||||
import * as api from '@/services/api'
|
||||
import type { Child } from '@/types'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
const mockChild: Child = {
|
||||
id: 'child1',
|
||||
workspaceId: 'ws1',
|
||||
name: 'Emma',
|
||||
birthDate: '2024-01-01',
|
||||
preschoolStartDate: null,
|
||||
preschoolPlanningDaysPerYear: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
describe('useChildSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('initializes with null data and not loading', () => {
|
||||
const { result } = renderHook(() => useChildSettings(null))
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fetch when childId is null', () => {
|
||||
renderHook(() => useChildSettings(null))
|
||||
expect(api.getChild).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches the child on mount and exposes the result', async () => {
|
||||
vi.mocked(api.getChild).mockResolvedValue(mockChild)
|
||||
|
||||
const { result } = renderHook(() => useChildSettings('child1'))
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
expect(api.getChild).toHaveBeenCalledWith('child1')
|
||||
expect(result.current.data).toEqual(mockChild)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('sets an error message when the fetch fails', async () => {
|
||||
vi.mocked(api.getChild).mockRejectedValue(new Error('Network down'))
|
||||
|
||||
const { result } = renderHook(() => useChildSettings('child1'))
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
expect(result.current.error).toBe('Network down')
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a generic error message for non-Error rejections', async () => {
|
||||
vi.mocked(api.getChild).mockRejectedValue('boom')
|
||||
|
||||
const { result } = renderHook(() => useChildSettings('child1'))
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
expect(result.current.error).toBe('Failed to load child')
|
||||
})
|
||||
|
||||
it('update() sends only the provided fields, snake_cased, and refetches', async () => {
|
||||
vi.mocked(api.getChild).mockResolvedValue(mockChild)
|
||||
vi.mocked(api.updateChildSettings).mockResolvedValue(mockChild)
|
||||
|
||||
const { result } = renderHook(() => useChildSettings('child1'))
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
vi.mocked(api.getChild).mockClear()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update({ birthDate: '2024-06-01' })
|
||||
})
|
||||
|
||||
expect(api.updateChildSettings).toHaveBeenCalledWith('child1', {
|
||||
birth_date: '2024-06-01',
|
||||
})
|
||||
// refetches after a successful update
|
||||
expect(api.getChild).toHaveBeenCalledWith('child1')
|
||||
})
|
||||
|
||||
it('update() includes preschoolPlanningDaysPerYear when provided', async () => {
|
||||
vi.mocked(api.getChild).mockResolvedValue(mockChild)
|
||||
vi.mocked(api.updateChildSettings).mockResolvedValue(mockChild)
|
||||
|
||||
const { result } = renderHook(() => useChildSettings('child1'))
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update({ preschoolPlanningDaysPerYear: 15 })
|
||||
})
|
||||
|
||||
expect(api.updateChildSettings).toHaveBeenCalledWith('child1', {
|
||||
preschool_planning_days_per_year: 15,
|
||||
})
|
||||
})
|
||||
|
||||
it('update() is a no-op when childId is null', async () => {
|
||||
const { result } = renderHook(() => useChildSettings(null))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update({ birthDate: '2024-06-01' })
|
||||
})
|
||||
|
||||
expect(api.updateChildSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Fetches and updates the full Child record (birth date, preschool settings) for
|
||||
* the settings page. Mirrors the useVacationDays pattern: typed api.* calls,
|
||||
* { data, isLoading, error }, and a mutation that refreshes afterwards.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import type { Child } from '@/types'
|
||||
|
||||
export interface ChildSettingsUpdatePayload {
|
||||
birthDate?: string
|
||||
preschoolPlanningDaysPerYear?: number
|
||||
}
|
||||
|
||||
interface UseChildSettingsResult {
|
||||
data: Child | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
update: (payload: ChildSettingsUpdatePayload) => Promise<void>
|
||||
}
|
||||
|
||||
export function useChildSettings(childId: string | null): UseChildSettingsResult {
|
||||
const [data, setData] = useState<Child | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!childId) {
|
||||
setData(null)
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.getChild(childId)
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load child')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [childId])
|
||||
|
||||
useEffect(() => {
|
||||
void fetch()
|
||||
}, [fetch])
|
||||
|
||||
const update = useCallback(
|
||||
async (payload: ChildSettingsUpdatePayload) => {
|
||||
if (!childId) return
|
||||
await api.updateChildSettings(childId, {
|
||||
...(payload.birthDate !== undefined ? { birth_date: payload.birthDate } : {}),
|
||||
...(payload.preschoolPlanningDaysPerYear !== undefined
|
||||
? { preschool_planning_days_per_year: payload.preschoolPlanningDaysPerYear }
|
||||
: {}),
|
||||
})
|
||||
await fetch()
|
||||
},
|
||||
[childId, fetch],
|
||||
)
|
||||
|
||||
return { data, isLoading, error, update }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useDayOverrides } from './useDayOverrides'
|
||||
import * as api from '@/services/api'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
describe('useDayOverrides', () => {
|
||||
const mockOverrides = [
|
||||
{
|
||||
id: 'do-1',
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-12-24',
|
||||
isWorkingDay: true,
|
||||
note: 'Jobbade på juldagen',
|
||||
createdAt: '2026-01-01T00:00:00',
|
||||
updatedAt: '2026-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'do-2',
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-06-20',
|
||||
isWorkingDay: false,
|
||||
note: 'Extra ledigt',
|
||||
createdAt: '2026-01-01T00:00:00',
|
||||
updatedAt: '2026-01-01T00:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.listDayOverridesByWorkspace).mockResolvedValue(mockOverrides)
|
||||
vi.mocked(api.upsertDayOverride).mockResolvedValue(mockOverrides[0] as any)
|
||||
vi.mocked(api.deleteDayOverride).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('fetches overrides on mount', async () => {
|
||||
const { result } = renderHook(() => useDayOverrides('ws-1'))
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.data).toEqual(mockOverrides)
|
||||
expect(api.listDayOverridesByWorkspace).toHaveBeenCalledWith('ws-1')
|
||||
})
|
||||
|
||||
it('does not fetch if workspaceId is empty', async () => {
|
||||
renderHook(() => useDayOverrides(''))
|
||||
await waitFor(() => {
|
||||
expect(api.listDayOverridesByWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('upserts an override and refetches', async () => {
|
||||
const { result } = renderHook(() => useDayOverrides('ws-1'))
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.data).toEqual(mockOverrides)
|
||||
|
||||
await result.current.upsert({
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-12-24',
|
||||
isWorkingDay: true,
|
||||
note: 'Test',
|
||||
})
|
||||
|
||||
expect(api.upsertDayOverride).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-12-24',
|
||||
isWorkingDay: true,
|
||||
note: 'Test',
|
||||
})
|
||||
expect(api.listDayOverridesByWorkspace).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('removes an override and refetches', async () => {
|
||||
const { result } = renderHook(() => useDayOverrides('ws-1'))
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
await result.current.remove('do-1')
|
||||
|
||||
expect(api.deleteDayOverride).toHaveBeenCalledWith('do-1')
|
||||
expect(api.listDayOverridesByWorkspace).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('sets error when fetch fails', async () => {
|
||||
const mockError = new Error('Network error')
|
||||
vi.mocked(api.listDayOverridesByWorkspace).mockRejectedValueOnce(mockError)
|
||||
|
||||
const { result } = renderHook(() => useDayOverrides('ws-1'))
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.error).toBe('Network error')
|
||||
expect(result.current.data).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import type { DayOverride, DayOverrideCreateRequest } from '@/types'
|
||||
|
||||
interface UseDayOverridesResult {
|
||||
data: DayOverride[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
upsert: (req: DayOverrideCreateRequest) => Promise<void>
|
||||
remove: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useDayOverrides(workspaceId: string): UseDayOverridesResult {
|
||||
const [data, setData] = useState<DayOverride[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.listDayOverridesByWorkspace(workspaceId)
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load day overrides')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
void fetch()
|
||||
}, [fetch])
|
||||
|
||||
const upsert = useCallback(
|
||||
async (req: DayOverrideCreateRequest) => {
|
||||
await api.upsertDayOverride(req)
|
||||
await fetch()
|
||||
},
|
||||
[fetch],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await api.deleteDayOverride(id)
|
||||
await fetch()
|
||||
},
|
||||
[fetch],
|
||||
)
|
||||
|
||||
return { data, isLoading, error, upsert, remove }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useLeaveWishes } from './useLeaveWishes'
|
||||
import * as api from '@/services/api'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
const mockWish = {
|
||||
id: 'lw1',
|
||||
workspaceId: 'ws1',
|
||||
childId: null,
|
||||
name: 'Sommar',
|
||||
windowStartMonth: 6,
|
||||
windowStartDay: 15,
|
||||
windowEndMonth: 7,
|
||||
windowEndDay: 26,
|
||||
targetWeeks: 6,
|
||||
recurrence: 'yearly' as const,
|
||||
firstYear: 2026,
|
||||
lastYear: null,
|
||||
priority: 1,
|
||||
parentMode: 'either' as const,
|
||||
parentPersonalDataUuid: null,
|
||||
goalOverride: null,
|
||||
windowMode: 'fixed' as const,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
}
|
||||
|
||||
describe('useLeaveWishes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('initializes with an empty list', () => {
|
||||
vi.mocked(api.getLeaveWishes).mockResolvedValue([])
|
||||
const { result } = renderHook(() => useLeaveWishes(null))
|
||||
expect(result.current.data).toEqual([])
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('does not fetch when workspaceId is null', () => {
|
||||
renderHook(() => useLeaveWishes(null))
|
||||
expect(api.getLeaveWishes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches wishes on mount', async () => {
|
||||
vi.mocked(api.getLeaveWishes).mockResolvedValue([mockWish])
|
||||
|
||||
const { result } = renderHook(() => useLeaveWishes('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(api.getLeaveWishes).toHaveBeenCalledWith('ws1')
|
||||
expect(result.current.data).toEqual([mockWish])
|
||||
})
|
||||
|
||||
it('handles fetch errors', async () => {
|
||||
vi.mocked(api.getLeaveWishes).mockRejectedValue(new Error('boom'))
|
||||
|
||||
const { result } = renderHook(() => useLeaveWishes('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toContain('boom')
|
||||
})
|
||||
|
||||
it('add() creates a wish and refreshes the list', async () => {
|
||||
vi.mocked(api.getLeaveWishes).mockResolvedValue([])
|
||||
vi.mocked(api.createLeaveWish).mockResolvedValue(mockWish)
|
||||
|
||||
const { result } = renderHook(() => useLeaveWishes('ws1'))
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.add({
|
||||
workspaceId: 'ws1',
|
||||
name: 'Sommar',
|
||||
windowStartMonth: 6,
|
||||
windowStartDay: 15,
|
||||
windowEndMonth: 7,
|
||||
windowEndDay: 26,
|
||||
targetWeeks: 6,
|
||||
recurrence: 'yearly',
|
||||
firstYear: 2026,
|
||||
})
|
||||
})
|
||||
|
||||
expect(api.createLeaveWish).toHaveBeenCalled()
|
||||
expect(api.getLeaveWishes).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('update() updates a wish and refreshes the list', async () => {
|
||||
vi.mocked(api.getLeaveWishes).mockResolvedValue([mockWish])
|
||||
vi.mocked(api.updateLeaveWish).mockResolvedValue({ ...mockWish, targetWeeks: 8 })
|
||||
|
||||
const { result } = renderHook(() => useLeaveWishes('ws1'))
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update('lw1', { targetWeeks: 8 })
|
||||
})
|
||||
|
||||
expect(api.updateLeaveWish).toHaveBeenCalledWith('lw1', { targetWeeks: 8 })
|
||||
})
|
||||
|
||||
it('remove() deletes a wish and refreshes the list', async () => {
|
||||
vi.mocked(api.getLeaveWishes).mockResolvedValue([mockWish])
|
||||
vi.mocked(api.deleteLeaveWish).mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useLeaveWishes('ws1'))
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.remove('lw1')
|
||||
})
|
||||
|
||||
expect(api.deleteLeaveWish).toHaveBeenCalledWith('lw1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import type { LeaveWish, LeaveWishCreateRequest, LeaveWishUpdateRequest } from '@/types'
|
||||
|
||||
interface UseLeaveWishesResult {
|
||||
data: LeaveWish[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
add: (req: LeaveWishCreateRequest) => Promise<LeaveWish>
|
||||
update: (id: string, req: LeaveWishUpdateRequest) => Promise<LeaveWish>
|
||||
remove: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** CRUD over a workspace's recurring leave wishes (`/api/leave-wishes`). */
|
||||
export function useLeaveWishes(workspaceId: string | null): UseLeaveWishesResult {
|
||||
const [data, setData] = useState<LeaveWish[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.getLeaveWishes(workspaceId)
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load leave wishes')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
void fetch()
|
||||
}, [fetch])
|
||||
|
||||
const add = useCallback(
|
||||
async (req: LeaveWishCreateRequest) => {
|
||||
const created = await api.createLeaveWish(req)
|
||||
await fetch()
|
||||
return created
|
||||
},
|
||||
[fetch],
|
||||
)
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string, req: LeaveWishUpdateRequest) => {
|
||||
const updated = await api.updateLeaveWish(id, req)
|
||||
await fetch()
|
||||
return updated
|
||||
},
|
||||
[fetch],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await api.deleteLeaveWish(id)
|
||||
await fetch()
|
||||
},
|
||||
[fetch],
|
||||
)
|
||||
|
||||
return { data, isLoading, error, refresh: fetch, add, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { usePlanApply } from './usePlanApply'
|
||||
import * as api from '@/services/api'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
describe('usePlanApply', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('initializes with null response and no loading/error', () => {
|
||||
const { result } = renderHook(() => usePlanApply())
|
||||
expect(result.current.response).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('calls applyPlan and returns the response', async () => {
|
||||
const mockResponse = {
|
||||
accepted: true,
|
||||
createdCount: 2,
|
||||
skippedIdenticalCount: 0,
|
||||
skippedFreeCount: 0,
|
||||
replacedCount: 0,
|
||||
collisions: [],
|
||||
rejections: [],
|
||||
createdDays: [],
|
||||
}
|
||||
|
||||
vi.mocked(api.applyPlan).mockResolvedValue(mockResponse)
|
||||
|
||||
const { result } = renderHook(() => usePlanApply())
|
||||
const days = [
|
||||
{
|
||||
date: '2026-06-15',
|
||||
parentPersonalDataUuid: 'pd-1',
|
||||
childId: 'c-1',
|
||||
dayType: 'sjukpenning' as const,
|
||||
isDubbeldag: false,
|
||||
},
|
||||
]
|
||||
|
||||
const applyPromise = result.current.apply('ws-1', days, false)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
const response = await applyPromise
|
||||
expect(response).toEqual(mockResponse)
|
||||
expect(result.current.response).toEqual(mockResponse)
|
||||
expect(api.applyPlan).toHaveBeenCalledWith('ws-1', days, false)
|
||||
})
|
||||
|
||||
it('sets error when applyPlan throws', async () => {
|
||||
vi.mocked(api.applyPlan).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() => usePlanApply())
|
||||
const days = [
|
||||
{
|
||||
date: '2026-06-15',
|
||||
parentPersonalDataUuid: 'pd-1',
|
||||
childId: 'c-1',
|
||||
dayType: 'sjukpenning' as const,
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
await result.current.apply('ws-1', days, false)
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Network error')
|
||||
expect(result.current.response).toBeNull()
|
||||
})
|
||||
|
||||
it('handles replace parameter correctly', async () => {
|
||||
const mockResponse = {
|
||||
accepted: true,
|
||||
createdCount: 1,
|
||||
skippedIdenticalCount: 0,
|
||||
skippedFreeCount: 0,
|
||||
replacedCount: 1,
|
||||
collisions: [],
|
||||
rejections: [],
|
||||
createdDays: [],
|
||||
}
|
||||
|
||||
vi.mocked(api.applyPlan).mockResolvedValue(mockResponse)
|
||||
|
||||
const { result } = renderHook(() => usePlanApply())
|
||||
const days = [
|
||||
{
|
||||
date: '2026-06-15',
|
||||
parentPersonalDataUuid: 'pd-1',
|
||||
childId: 'c-1',
|
||||
dayType: 'sjukpenning' as const,
|
||||
},
|
||||
]
|
||||
|
||||
const applyPromise = result.current.apply('ws-1', days, true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
const response = await applyPromise
|
||||
expect(api.applyPlan).toHaveBeenCalledWith('ws-1', days, true)
|
||||
expect(response).toEqual(mockResponse)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.response).toEqual(mockResponse)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import type { ProposedDayApplyItem, PlanApplyResponse } from '@/types'
|
||||
|
||||
interface UsePlanApplyResult {
|
||||
response: PlanApplyResponse | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
apply: (workspaceId: string, days: ProposedDayApplyItem[], replace: boolean) => Promise<PlanApplyResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a plan (persists proposed days as real planned days).
|
||||
* Returns the full response including collision info, rejections, and created counts.
|
||||
*/
|
||||
export function usePlanApply(): UsePlanApplyResult {
|
||||
const [response, setResponse] = useState<PlanApplyResponse | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const apply = useCallback(
|
||||
async (workspaceId: string, days: ProposedDayApplyItem[], replace: boolean) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.applyPlan(workspaceId, days, replace)
|
||||
setResponse(result)
|
||||
return result
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Kunde inte använda plan'
|
||||
setError(message)
|
||||
setResponse(null)
|
||||
throw err
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return { response, isLoading, error, apply }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { usePlanProposal } from './usePlanProposal'
|
||||
import * as api from '@/services/api'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
const mockProposal = {
|
||||
days: [],
|
||||
outcomes: [],
|
||||
warnings: [],
|
||||
poolBalances: [],
|
||||
vacationBalances: [],
|
||||
surplusAdvice: [],
|
||||
age4Projections: [],
|
||||
}
|
||||
|
||||
describe('usePlanProposal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('initializes with null data and does not call the API on mount', () => {
|
||||
const { result } = renderHook(() => usePlanProposal('ws1'))
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(api.proposePlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propose() calls the API and stores the result', async () => {
|
||||
vi.mocked(api.proposePlan).mockResolvedValue(mockProposal)
|
||||
|
||||
const { result } = renderHook(() => usePlanProposal('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.propose()
|
||||
})
|
||||
|
||||
expect(api.proposePlan).toHaveBeenCalledWith('ws1')
|
||||
expect(result.current.data).toEqual(mockProposal)
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('propose() is a no-op when workspaceId is null', async () => {
|
||||
const { result } = renderHook(() => usePlanProposal(null))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.propose()
|
||||
})
|
||||
|
||||
expect(api.proposePlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propose() records an error and clears data on failure', async () => {
|
||||
vi.mocked(api.proposePlan).mockRejectedValue(new Error('kaputt'))
|
||||
|
||||
const { result } = renderHook(() => usePlanProposal('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.propose()
|
||||
})
|
||||
|
||||
expect(result.current.error).toContain('kaputt')
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import type { PlanProposal } from '@/types'
|
||||
|
||||
interface UsePlanProposalResult {
|
||||
data: PlanProposal | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
propose: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers `POST /api/plan/propose` on demand (not on mount) — the user asks
|
||||
* for a proposal by clicking "Föreslå plan"; nothing is persisted server-side.
|
||||
*/
|
||||
export function usePlanProposal(workspaceId: string | null): UsePlanProposalResult {
|
||||
const [data, setData] = useState<PlanProposal | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const propose = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.proposePlan(workspaceId)
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to compute plan proposal')
|
||||
setData(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
return { data, isLoading, error, propose }
|
||||
}
|
||||
@@ -88,4 +88,291 @@ describe('usePlannedDays', () => {
|
||||
expect(api.deletePlannedDay).toHaveBeenCalledWith('pd1')
|
||||
expect(api.getPlannedDays).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles fetch error and sets error state', async () => {
|
||||
const errorMessage = 'Network error'
|
||||
vi.mocked(api.getPlannedDays).mockRejectedValue(new Error(errorMessage))
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('handles fetch error with non-Error object', async () => {
|
||||
vi.mocked(api.getPlannedDays).mockRejectedValue('Unknown error')
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Failed to fetch planned days')
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('updates planned day and refreshes', async () => {
|
||||
const mockUpdated = {
|
||||
id: '1',
|
||||
workspaceId: 'ws1',
|
||||
childId: 'child1',
|
||||
parentPersonalDataUuid: 'parent1',
|
||||
date: '2026-06-16',
|
||||
dayType: 'lagsta' as const,
|
||||
isDubbeldag: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
vi.mocked(api.updatePlannedDay).mockResolvedValue(mockUpdated as any)
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([mockUpdated])
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update('1', { day_type: 'lagsta' })
|
||||
})
|
||||
|
||||
expect(api.updatePlannedDay).toHaveBeenCalledWith('1', { day_type: 'lagsta' })
|
||||
expect(api.getPlannedDays).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates data after successful add', async () => {
|
||||
const initialData = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws1',
|
||||
childId: 'child1',
|
||||
parentPersonalDataUuid: 'parent1',
|
||||
date: '2026-06-15',
|
||||
dayType: 'vacation' as const,
|
||||
isDubbeldag: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
const newData = {
|
||||
id: '2',
|
||||
workspaceId: 'ws1',
|
||||
childId: 'child1',
|
||||
parentPersonalDataUuid: 'parent1',
|
||||
date: '2026-06-16',
|
||||
dayType: 'vacation' as const,
|
||||
isDubbeldag: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData as any)
|
||||
vi.mocked(api.createPlannedDay).mockResolvedValue(newData as any)
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValueOnce([...initialData, newData] as any)
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(initialData)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.add({ workspace_id: 'ws1', child_id: 'child1', date: '2026-06-16', day_type: 'vacation', parent_personal_data_uuid: 'parent1' })
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual([...initialData, newData])
|
||||
})
|
||||
|
||||
it('updates data after successful remove', async () => {
|
||||
const initialData = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws1',
|
||||
childId: 'child1',
|
||||
parentPersonalDataUuid: 'parent1',
|
||||
date: '2026-06-15',
|
||||
dayType: 'vacation' as const,
|
||||
isDubbeldag: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws1',
|
||||
childId: 'child1',
|
||||
parentPersonalDataUuid: 'parent1',
|
||||
date: '2026-06-16',
|
||||
dayType: 'vacation' as const,
|
||||
isDubbeldag: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData as any)
|
||||
vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined)
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValueOnce([initialData[0]] as any)
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(initialData)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.remove('2')
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual([initialData[0]])
|
||||
})
|
||||
|
||||
it('refreshes data explicitly', async () => {
|
||||
const mockData = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws1',
|
||||
childId: 'child1',
|
||||
parentPersonalDataUuid: 'parent1',
|
||||
date: '2026-06-15',
|
||||
dayType: 'vacation' as const,
|
||||
isDubbeldag: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue(mockData as any)
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(api.getPlannedDays).toHaveBeenCalledWith('child1')
|
||||
const callCount = vi.mocked(api.getPlannedDays).mock.calls.length
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh()
|
||||
})
|
||||
|
||||
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCount)
|
||||
})
|
||||
|
||||
it('sets loading state during fetch', async () => {
|
||||
let resolveGetPlannedDays: (value: any) => void
|
||||
const getPlannedDaysPromise = new Promise(resolve => {
|
||||
resolveGetPlannedDays = resolve
|
||||
})
|
||||
|
||||
vi.mocked(api.getPlannedDays).mockReturnValue(getPlannedDaysPromise as any)
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
resolveGetPlannedDays!([])
|
||||
await getPlannedDaysPromise
|
||||
})
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('clears error on successful fetch after error', async () => {
|
||||
vi.mocked(api.getPlannedDays).mockRejectedValueOnce(new Error('Network error'))
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValueOnce([])
|
||||
|
||||
const { result, rerender } = renderHook(({ childId }: { childId: string }) => usePlannedDays(childId), {
|
||||
initialProps: { childId: 'child1' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Network error')
|
||||
|
||||
rerender({ childId: 'child2' })
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('handles add error gracefully', async () => {
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
||||
vi.mocked(api.createPlannedDay).mockRejectedValue(new Error('Create failed'))
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
let addError: Error | undefined
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.add({ workspace_id: 'ws1', child_id: 'child1', date: '2026-06-16', day_type: 'vacation', parent_personal_data_uuid: 'parent1' })
|
||||
} catch (err) {
|
||||
addError = err as Error
|
||||
}
|
||||
})
|
||||
|
||||
expect(addError?.message).toBe('Create failed')
|
||||
})
|
||||
|
||||
it('handles update error gracefully', async () => {
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
||||
vi.mocked(api.updatePlannedDay).mockRejectedValue(new Error('Update failed'))
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
let updateError: Error | undefined
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.update('1', { day_type: 'lagsta' })
|
||||
} catch (err) {
|
||||
updateError = err as Error
|
||||
}
|
||||
})
|
||||
|
||||
expect(updateError?.message).toBe('Update failed')
|
||||
})
|
||||
|
||||
it('handles remove error gracefully', async () => {
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
||||
vi.mocked(api.deletePlannedDay).mockRejectedValue(new Error('Delete failed'))
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
let removeError: Error | undefined
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.remove('1')
|
||||
} catch (err) {
|
||||
removeError = err as Error
|
||||
}
|
||||
})
|
||||
|
||||
expect(removeError?.message).toBe('Delete failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useWorkspaceMembers } from './useWorkspaceMembers'
|
||||
import * as api from '@/services/api'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
const mockMember = {
|
||||
uuid: 'pd1',
|
||||
workspaceId: 'ws1',
|
||||
name: 'Alex',
|
||||
salaryType: 'monthly' as const,
|
||||
employmentStatus: 'employed' as const,
|
||||
createdAt: '2026-01-01',
|
||||
}
|
||||
|
||||
describe('useWorkspaceMembers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not fetch when workspaceId is null', () => {
|
||||
renderHook(() => useWorkspaceMembers(null))
|
||||
expect(api.listWorkspaceMembers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches members on mount', async () => {
|
||||
vi.mocked(api.listWorkspaceMembers).mockResolvedValue([mockMember])
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceMembers('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(api.listWorkspaceMembers).toHaveBeenCalledWith('ws1')
|
||||
expect(result.current.data).toEqual([mockMember])
|
||||
})
|
||||
|
||||
it('handles fetch errors', async () => {
|
||||
vi.mocked(api.listWorkspaceMembers).mockRejectedValue(new Error('boom'))
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceMembers('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toContain('boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import type { PersonalData } from '@/types'
|
||||
|
||||
interface UseWorkspaceMembersResult {
|
||||
data: PersonalData[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
/** The parents in a workspace — used to pick "which parent" a wish belongs to. */
|
||||
export function useWorkspaceMembers(workspaceId: string | null): UseWorkspaceMembersResult {
|
||||
const [data, setData] = useState<PersonalData[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.listWorkspaceMembers(workspaceId)
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load workspace members')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
void fetch()
|
||||
}, [fetch])
|
||||
|
||||
return { data, isLoading, error, refresh: fetch }
|
||||
}
|
||||
Reference in New Issue
Block a user