Initial commit

This commit is contained in:
2026-06-01 16:48:21 +02:00
commit 53d157535a
159 changed files with 13641 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useAnalytics } from './useAnalytics'
import * as api from '@/services/api'
vi.mock('@/services/api')
describe('useAnalytics', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('initializes with null data', () => {
const { result } = renderHook(() => useAnalytics(null))
expect(result.current.data).toBeNull()
expect(result.current.isLoading).toBe(false)
})
it('fetches analytics data on mount', async () => {
const mockData = {
workspace_id: 'ws1',
total_days: 25,
days_used: 10,
days_left: 15,
projected_left_at_age_4: 50,
}
vi.mocked(api.getWorkspaceOverview).mockResolvedValue(mockData as any)
const { result } = renderHook(() => useAnalytics('ws1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(api.getWorkspaceOverview).toHaveBeenCalledWith('ws1')
})
it('handles fetch errors', async () => {
vi.mocked(api.getWorkspaceOverview).mockRejectedValue(new Error('API error'))
const { result } = renderHook(() => useAnalytics('ws1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.error).toContain('API error')
})
it('does not fetch when workspaceId is null', () => {
vi.mocked(api.getWorkspaceOverview).mockResolvedValue({} as any)
renderHook(() => useAnalytics(null))
expect(api.getWorkspaceOverview).not.toHaveBeenCalled()
})
it('provides refresh function', async () => {
vi.mocked(api.getWorkspaceOverview).mockResolvedValue({} as any)
const { result } = renderHook(() => useAnalytics('ws1'))
await act(async () => {
await result.current.refresh()
})
expect(api.getWorkspaceOverview).toHaveBeenCalled()
})
})
+37
View File
@@ -0,0 +1,37 @@
import { useState, useEffect, useCallback } from 'react'
import * as api from '@/services/api'
import type { WorkspaceOverview } from '@/types'
interface UseAnalyticsResult {
data: WorkspaceOverview | null
isLoading: boolean
error: string | null
refresh: () => Promise<void>
}
export function useAnalytics(workspaceId: string | null): UseAnalyticsResult {
const [data, setData] = useState<WorkspaceOverview | 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.getWorkspaceOverview(workspaceId)
setData(response)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch analytics')
setData(null)
} finally {
setIsLoading(false)
}
}, [workspaceId])
useEffect(() => {
fetch()
}, [fetch])
return { data, isLoading, error, refresh: fetch }
}
+102
View File
@@ -0,0 +1,102 @@
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',
workspace_id: 'ws1',
name: 'Summer',
start_date: '2026-06-01',
end_date: '2026-08-31',
target_weeks: 8,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
]
vi.mocked(api.getBreakPeriods).mockResolvedValue(mockData as any)
const { result } = 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()
})
})
+67
View File
@@ -0,0 +1,67 @@
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,
start_date: startDate,
end_date: endDate,
target_weeks: 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,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useOptimizationTips } from './useOptimizationTips'
import * as api from '@/services/api'
vi.mock('@/services/api')
describe('useOptimizationTips', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('initializes with null data', () => {
const { result } = renderHook(() => useOptimizationTips(null))
expect(result.current.data).toBeNull()
expect(result.current.isLoading).toBe(false)
})
it('fetches tips on mount', async () => {
const mockTips = [
{
id: 'tip1',
severity: 'info',
message_key: 'wrap_red_days',
data: {},
},
]
vi.mocked(api.getTips).mockResolvedValue(mockTips as any)
const { result } = renderHook(() => useOptimizationTips('ws1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(api.getTips).toHaveBeenCalledWith('ws1')
})
it('handles fetch errors', async () => {
vi.mocked(api.getTips).mockRejectedValue(new Error('API error'))
const { result } = renderHook(() => useOptimizationTips('ws1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.error).toContain('API error')
})
it('dismisses tips locally', async () => {
const mockTips = [
{ id: 'tip1', severity: 'info', message_key: 'msg1', data: {} },
{ id: 'tip2', severity: 'warning', message_key: 'msg2', data: {} },
]
vi.mocked(api.getTips).mockResolvedValue(mockTips as any)
const { result } = renderHook(() => useOptimizationTips('ws1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.data).toHaveLength(2)
act(() => {
result.current.dismiss('tip1')
})
expect(result.current.data).toHaveLength(1)
expect(result.current.data?.[0].id).toBe('tip2')
})
it('provides refresh function', async () => {
vi.mocked(api.getTips).mockResolvedValue([])
const { result } = renderHook(() => useOptimizationTips('ws1'))
await act(async () => {
await result.current.refresh()
})
expect(api.getTips).toHaveBeenCalled()
})
it('does not fetch when workspaceId is null', () => {
vi.mocked(api.getTips).mockResolvedValue([])
renderHook(() => useOptimizationTips(null))
expect(api.getTips).not.toHaveBeenCalled()
})
})
+45
View File
@@ -0,0 +1,45 @@
import { useState, useEffect, useCallback } from 'react'
import * as api from '@/services/api'
import type { Tip } from '@/types'
interface UseOptimizationTipsResult {
data: Tip[] | null
isLoading: boolean
error: string | null
refresh: () => Promise<void>
dismiss: (tipId: string) => void
}
export function useOptimizationTips(workspaceId: string | null): UseOptimizationTipsResult {
const [data, setData] = useState<Tip[] | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
const fetch = useCallback(async () => {
if (!workspaceId) return
setIsLoading(true)
setError(null)
try {
const response = await api.getTips(workspaceId)
setData(response)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch tips')
setData(null)
} finally {
setIsLoading(false)
}
}, [workspaceId])
const dismiss = useCallback((tipId: string) => {
setDismissed((prev) => new Set([...prev, tipId]))
}, [])
useEffect(() => {
fetch()
}, [fetch])
const filteredData = data?.filter((tip) => !dismissed.has(tip.id)) ?? null
return { data: filteredData, isLoading, error, refresh: fetch, dismiss }
}
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useParentalLeave } from './useParentalLeave'
import * as api from '@/services/api'
vi.mock('@/services/api')
describe('useParentalLeave', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('initializes with null data', () => {
const { result } = renderHook(() => useParentalLeave(null))
expect(result.current.data).toBeNull()
expect(result.current.isLoading).toBe(false)
})
it('fetches parental leave data on mount', async () => {
const mockData = {
workspace_id: 'ws1',
days_used: 20,
days_available: 300,
}
vi.mocked(api.getParentalLeave).mockResolvedValue(mockData as any)
const { result } = renderHook(() => useParentalLeave('ws1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(api.getParentalLeave).toHaveBeenCalledWith('ws1')
})
it('handles fetch errors', async () => {
vi.mocked(api.getParentalLeave).mockRejectedValue(new Error('Fetch failed'))
const { result } = renderHook(() => useParentalLeave('ws1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.error).toContain('Fetch failed')
})
it('does not fetch when workspaceId is null', () => {
vi.mocked(api.getParentalLeave).mockResolvedValue({} as any)
renderHook(() => useParentalLeave(null))
expect(api.getParentalLeave).not.toHaveBeenCalled()
})
it('provides refresh function', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue({} as any)
const { result } = renderHook(() => useParentalLeave('ws1'))
await act(async () => {
await result.current.refresh()
})
expect(api.getParentalLeave).toHaveBeenCalled()
})
})
+37
View File
@@ -0,0 +1,37 @@
import { useState, useEffect, useCallback } from 'react'
import * as api from '@/services/api'
import type { ParentalLeaveFetch } from '@/types'
interface UseParentalLeaveResult {
data: ParentalLeaveFetch | null
isLoading: boolean
error: string | null
refresh: () => Promise<void>
}
export function useParentalLeave(workspaceId: string | null): UseParentalLeaveResult {
const [data, setData] = useState<ParentalLeaveFetch | 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.getParentalLeave(workspaceId)
setData(response)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
setData(null)
} finally {
setIsLoading(false)
}
}, [workspaceId])
useEffect(() => {
fetch()
}, [fetch])
return { data, isLoading, error, refresh: fetch }
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { usePlannedDays } from './usePlannedDays'
import * as api from '@/services/api'
vi.mock('@/services/api')
describe('usePlannedDays', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('initializes with null data', () => {
const { result } = renderHook(() => usePlannedDays(null))
expect(result.current.data).toBeNull()
expect(result.current.isLoading).toBe(false)
})
it('fetches planned days when both ids provided', async () => {
const mockData = [
{
id: '1',
workspace_id: 'ws1',
child_id: 'child1',
date: '2026-06-15',
day_type: 'vacation',
is_dubbeldag: false,
created_at: new Date().toISOString(),
},
]
vi.mocked(api.getPlannedDays).mockResolvedValue(mockData as any)
const { result } = renderHook(() => usePlannedDays('ws1', 'child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(api.getPlannedDays).toHaveBeenCalledWith('ws1', 'child1')
})
it('does not fetch without childId', () => {
vi.mocked(api.getPlannedDays).mockResolvedValue([])
renderHook(() => usePlannedDays('ws1'))
expect(api.getPlannedDays).not.toHaveBeenCalled()
})
it('adds planned day and refreshes', async () => {
const mockAdded = {
id: '2',
workspace_id: 'ws1',
child_id: 'child1',
date: '2026-06-16',
day_type: 'vacation',
is_dubbeldag: false,
created_at: new Date().toISOString(),
}
vi.mocked(api.createPlannedDay).mockResolvedValue(mockAdded as any)
vi.mocked(api.getPlannedDays).mockResolvedValue([mockAdded])
const { result } = renderHook(() => usePlannedDays('ws1', 'child1'))
await act(async () => {
await result.current.add('2026-06-16', 'child1', 'vacation')
})
expect(api.createPlannedDay).toHaveBeenCalled()
})
it('removes planned day and refreshes', async () => {
vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined)
vi.mocked(api.getPlannedDays).mockResolvedValue([])
const { result } = renderHook(() => usePlannedDays('ws1', 'child1'))
await act(async () => {
await result.current.remove('pd1')
})
expect(api.deletePlannedDay).toHaveBeenCalledWith('pd1')
})
})
+60
View File
@@ -0,0 +1,60 @@
import { useState, useEffect, useCallback } from 'react'
import * as api from '@/services/api'
import type { PlannedLeaveDay } from '@/types'
interface UsePlannedDaysResult {
data: PlannedLeaveDay[] | null
isLoading: boolean
error: string | null
refresh: () => Promise<void>
add: (date: string, childId: string, leaveType: string) => Promise<PlannedLeaveDay>
remove: (id: string) => Promise<void>
}
export function usePlannedDays(workspaceId: string | null, childId?: string): UsePlannedDaysResult {
const [data, setData] = useState<PlannedLeaveDay[] | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetch = useCallback(async () => {
if (!workspaceId || !childId) return
setIsLoading(true)
setError(null)
try {
const response = await api.getPlannedDays(workspaceId, childId)
setData(response)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch planned days')
setData(null)
} finally {
setIsLoading(false)
}
}, [workspaceId, childId])
const add = useCallback(
async (date: string, childId: string, leaveType: string) => {
if (!workspaceId) throw new Error('Workspace not selected')
const result = await api.createPlannedDay(workspaceId, childId, {
date,
day_type: leaveType,
})
await fetch()
return result
},
[workspaceId, fetch]
)
const remove = useCallback(
async (id: string) => {
await api.deletePlannedDay(id)
await fetch()
},
[fetch]
)
useEffect(() => {
fetch()
}, [fetch])
return { data, isLoading, error, refresh: fetch, add, remove }
}
+54
View File
@@ -0,0 +1,54 @@
/**
* REFERENCE HOOK — mirrors the VacationDays backend slice on the frontend.
*
* Pattern to copy for other hooks (usePlannedDays, useParentalLeave, etc.):
* 1. Call the typed api.* function.
* 2. Track { data, isLoading, error }.
* 3. Expose a mutation (upsert/create/delete) that calls the API and refreshes.
* 4. Write a test in tests/unit/hooks/ using vi.mock('@/services/api').
*/
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import type { VacationDays, VacationDaysCreateRequest } from '@/types'
interface UseVacationDaysResult {
data: VacationDays[]
isLoading: boolean
error: string | null
upsert: (req: VacationDaysCreateRequest) => Promise<void>
}
export function useVacationDays(workspaceId: string): UseVacationDaysResult {
const [data, setData] = useState<VacationDays[]>([])
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.getVacationDaysByWorkspace(workspaceId)
setData(result)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load vacation days')
} finally {
setIsLoading(false)
}
}, [workspaceId])
useEffect(() => {
void fetch()
}, [fetch])
const upsert = useCallback(
async (req: VacationDaysCreateRequest) => {
await api.upsertVacationDays(req)
await fetch()
},
[fetch],
)
return { data, isLoading, error, upsert }
}