MVP frontend+backend

This commit is contained in:
2026-06-14 09:58:30 +02:00
parent d9539f23cc
commit 772d853e37
56 changed files with 5099 additions and 207 deletions
+1
View File
@@ -4,3 +4,4 @@ export { useOptimizationTips } from './useOptimizationTips'
export { useParentalLeave } from './useParentalLeave'
export { usePlannedDays } from './usePlannedDays'
export { useVacationDays } from './useVacationDays'
export { useScraperFetch } from './useScraperFetch'
+7 -7
View File
@@ -11,7 +11,7 @@ describe('useParentalLeave', () => {
})
it('initializes with null data', () => {
const { result } = renderHook(() => useParentalLeave(null))
const { result } = renderHook(() => useParentalLeave(null, null))
expect(result.current.data).toBeNull()
expect(result.current.isLoading).toBe(false)
})
@@ -38,19 +38,19 @@ describe('useParentalLeave', () => {
vi.mocked(api.getParentalLeave).mockResolvedValue(mockData as any)
renderHook(() => useParentalLeave('ws1'))
renderHook(() => useParentalLeave('pd1', 'child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(api.getParentalLeave).toHaveBeenCalledWith('ws1')
expect(api.getParentalLeave).toHaveBeenCalledWith('pd1', 'child1')
})
it('handles fetch errors', async () => {
vi.mocked(api.getParentalLeave).mockRejectedValue(new Error('Fetch failed'))
const { result } = renderHook(() => useParentalLeave('ws1'))
const { result } = renderHook(() => useParentalLeave('pd1', 'child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
@@ -59,10 +59,10 @@ describe('useParentalLeave', () => {
expect(result.current.error).toContain('Fetch failed')
})
it('does not fetch when workspaceId is null', () => {
it('does not fetch when personalDataUuid or childId is null', () => {
vi.mocked(api.getParentalLeave).mockResolvedValue({} as any)
renderHook(() => useParentalLeave(null))
renderHook(() => useParentalLeave(null, null))
expect(api.getParentalLeave).not.toHaveBeenCalled()
})
@@ -70,7 +70,7 @@ describe('useParentalLeave', () => {
it('provides refresh function', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue({} as any)
const { result } = renderHook(() => useParentalLeave('ws1'))
const { result } = renderHook(() => useParentalLeave('pd1', 'child1'))
await act(async () => {
await result.current.refresh()
+7 -4
View File
@@ -9,17 +9,20 @@ interface UseParentalLeaveResult {
refresh: () => Promise<void>
}
export function useParentalLeave(workspaceId: string | null): UseParentalLeaveResult {
export function useParentalLeave(
personalDataUuid: string | null,
childId: 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
if (!personalDataUuid || !childId) return
setIsLoading(true)
setError(null)
try {
const response = await api.getParentalLeave(workspaceId)
const response = await api.getParentalLeave(personalDataUuid, childId)
setData(response)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
@@ -27,7 +30,7 @@ export function useParentalLeave(workspaceId: string | null): UseParentalLeaveRe
} finally {
setIsLoading(false)
}
}, [workspaceId])
}, [personalDataUuid, childId])
useEffect(() => {
fetch()
+7 -7
View File
@@ -11,12 +11,12 @@ describe('usePlannedDays', () => {
})
it('initializes with null data', () => {
const { result } = renderHook(() => usePlannedDays(null))
const { result } = renderHook(() => usePlannedDays())
expect(result.current.data).toBeNull()
expect(result.current.isLoading).toBe(false)
})
it('fetches planned days when both ids provided', async () => {
it('fetches planned days when childId provided', async () => {
const mockData = [
{
id: '1',
@@ -33,19 +33,19 @@ describe('usePlannedDays', () => {
vi.mocked(api.getPlannedDays).mockResolvedValue(mockData as any)
renderHook(() => usePlannedDays('ws1', 'child1'))
renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(api.getPlannedDays).toHaveBeenCalledWith('ws1', 'child1')
expect(api.getPlannedDays).toHaveBeenCalledWith('child1')
})
it('does not fetch without childId', () => {
vi.mocked(api.getPlannedDays).mockResolvedValue([])
renderHook(() => usePlannedDays('ws1'))
renderHook(() => usePlannedDays())
expect(api.getPlannedDays).not.toHaveBeenCalled()
})
@@ -66,7 +66,7 @@ describe('usePlannedDays', () => {
vi.mocked(api.createPlannedDay).mockResolvedValue(mockAdded as any)
vi.mocked(api.getPlannedDays).mockResolvedValue([mockAdded])
renderHook(() => usePlannedDays('ws1', 'child1'))
renderHook(() => usePlannedDays('child1'))
await act(async () => {
// just test that add can be called; mocks handle the result
@@ -79,7 +79,7 @@ describe('usePlannedDays', () => {
vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined)
vi.mocked(api.getPlannedDays).mockResolvedValue([])
const { result } = renderHook(() => usePlannedDays('ws1', 'child1'))
const { result } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await result.current.remove('pd1')
+6 -7
View File
@@ -11,17 +11,17 @@ interface UsePlannedDaysResult {
remove: (id: string) => Promise<void>
}
export function usePlannedDays(workspaceId: string | null, childId?: string): UsePlannedDaysResult {
export function usePlannedDays(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
if (!childId) return
setIsLoading(true)
setError(null)
try {
const response = await api.getPlannedDays(workspaceId, childId)
const response = await api.getPlannedDays(childId)
setData(response)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch planned days')
@@ -29,19 +29,18 @@ export function usePlannedDays(workspaceId: string | null, childId?: string): Us
} finally {
setIsLoading(false)
}
}, [workspaceId, childId])
}, [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, {
const result = await api.createPlannedDay(childId, {
date,
leaveType,
})
await fetch()
return result
},
[workspaceId, fetch]
[fetch]
)
const remove = useCallback(
+172
View File
@@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useScraperFetch } from './useScraperFetch'
import * as api from '@/services/api'
import * as errors from '@/services/errors'
vi.mock('@/services/api')
vi.mock('@/services/errors')
describe('useScraperFetch', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('initializes with null state', () => {
const { result } = renderHook(() => useScraperFetch())
expect(result.current.sessionId).toBeNull()
expect(result.current.pollData).toBeNull()
expect(result.current.error).toBeNull()
expect(result.current.isRunning).toBe(false)
expect(result.current.isTerminal).toBe(false)
})
it('starts scraping session', async () => {
vi.mocked(api.startScraperSession).mockResolvedValue({
session_id: 'sess-123',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start('child-1')
})
expect(result.current.sessionId).toBe('sess-123')
expect(result.current.pollData?.status).toBe('launching')
expect(api.startScraperSession).toHaveBeenCalledWith({ child_id: 'child-1' })
})
it('transitions through polling states', async () => {
vi.mocked(api.startScraperSession).mockResolvedValue({
session_id: 'sess-123',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
vi.mocked(api.pollScraperSession)
.mockResolvedValueOnce({ status: 'awaiting_scan', qr_data: 'qr-123' } as any)
.mockResolvedValueOnce({ status: 'authenticating' } as any)
.mockResolvedValueOnce({ status: 'fetching' } as any)
.mockResolvedValueOnce({ status: 'done', result: { data: 'fetched' } } as any)
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
// First poll
await act(async () => {
vi.advanceTimersByTime(1000)
})
expect(result.current.pollData?.status).toBe('awaiting_scan')
expect(result.current.isRunning).toBe(true)
// Second poll
await act(async () => {
vi.advanceTimersByTime(1000)
})
expect(result.current.pollData?.status).toBe('authenticating')
// Third poll
await act(async () => {
vi.advanceTimersByTime(1000)
})
expect(result.current.pollData?.status).toBe('fetching')
// Fourth poll - terminal status
await act(async () => {
vi.advanceTimersByTime(1000)
})
expect(result.current.pollData?.status).toBe('done')
expect(result.current.isRunning).toBe(false)
expect(result.current.isTerminal).toBe(true)
})
it('cancels scraping session', async () => {
vi.mocked(api.startScraperSession).mockResolvedValue({
session_id: 'sess-123',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
vi.mocked(api.pollScraperSession).mockResolvedValue({ status: 'awaiting_scan' } as any)
vi.mocked(api.cancelScraperSession).mockResolvedValue(undefined)
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
await act(async () => {
await result.current.cancel()
})
expect(api.cancelScraperSession).toHaveBeenCalledWith('sess-123')
expect(result.current.pollData?.status).toBe('cancelled')
expect(result.current.sessionId).toBeNull()
})
it('handles start error', async () => {
vi.mocked(api.startScraperSession).mockRejectedValue(new Error('Start failed'))
vi.mocked(errors.isUnauthorized).mockReturnValue(false)
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
expect(result.current.error).toContain('Start failed')
expect(result.current.sessionId).toBeNull()
})
it('handles unauthorized error on start', async () => {
vi.mocked(api.startScraperSession).mockRejectedValue(new Error('Unauthorized'))
vi.mocked(errors.isUnauthorized).mockReturnValue(true)
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
expect(result.current.isUnauthorized).toBe(true)
expect(result.current.error).toBe('Not authenticated')
})
it('handles poll error', async () => {
vi.mocked(api.startScraperSession).mockResolvedValue({
session_id: 'sess-123',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
vi.mocked(api.pollScraperSession).mockRejectedValue(new Error('Poll failed'))
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
await act(async () => {
vi.advanceTimersByTime(1000)
})
expect(result.current.error).toContain('Poll failed')
})
})
+132
View File
@@ -0,0 +1,132 @@
import { useState, useRef, useCallback } from 'react'
import * as api from '@/services/api'
import { isUnauthorized } from '@/services/errors'
export type ScraperStatus = 'idle' | 'launching' | 'awaiting_scan' | 'authenticating' | 'fetching' | 'done' | 'error' | 'cancelled'
interface PollResponse {
status: ScraperStatus
qr_data?: string
result?: Record<string, unknown>
message?: string
}
interface ResolvedIds {
workspace_id: string
personal_data_uuid: string
child_id: string
}
const POLL_INTERVAL_MS = 1000
interface UseScraperFetchResult {
sessionId: string | null
pollData: PollResponse | null
resolvedIds: ResolvedIds | null
error: string | null
isUnauthorized: boolean
start: (childId?: string) => Promise<void>
cancel: () => Promise<void>
isRunning: boolean
isTerminal: boolean
}
export function useScraperFetch(): UseScraperFetchResult {
const [sessionId, setSessionId] = useState<string | null>(null)
const [pollData, setPollData] = useState<PollResponse | null>(null)
const [resolvedIds, setResolvedIds] = useState<ResolvedIds | null>(null)
const [error, setError] = useState<string | null>(null)
const [unauthorizedError, setUnauthorizedError] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const stopPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
const isTerminalStatus = (s: ScraperStatus) =>
s === 'done' || s === 'error' || s === 'cancelled'
const startPolling = useCallback((sid: string) => {
stopPolling()
intervalRef.current = setInterval(async () => {
try {
const data = await api.pollScraperSession(sid)
setPollData(data as PollResponse)
if (isTerminalStatus((data as PollResponse).status)) {
stopPolling()
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Poll error')
stopPolling()
}
}, POLL_INTERVAL_MS)
}, [stopPolling])
const start = useCallback(
async (childId?: string) => {
if (sessionId && pollData && !isTerminalStatus(pollData.status)) {
stopPolling()
try {
await api.cancelScraperSession(sessionId)
} catch { /* ignore */ }
}
setError(null)
setUnauthorizedError(false)
setPollData(null)
setSessionId(null)
setResolvedIds(null)
try {
const payload = childId ? { child_id: childId } : {}
const resp = await api.startScraperSession(payload)
setSessionId(resp.session_id)
setResolvedIds({
workspace_id: resp.workspace_id,
personal_data_uuid: resp.personal_data_uuid,
child_id: resp.child_id,
})
setPollData({ status: 'launching' })
startPolling(resp.session_id)
} catch (err) {
if (isUnauthorized(err)) {
setUnauthorizedError(true)
setError('Not authenticated')
} else {
setError(err instanceof Error ? err.message : 'Start error')
}
}
},
[sessionId, pollData, stopPolling, startPolling],
)
const cancel = useCallback(async () => {
stopPolling()
if (sessionId) {
try {
await api.cancelScraperSession(sessionId)
} catch { /* ignore */ }
}
setPollData({ status: 'cancelled' })
setSessionId(null)
}, [sessionId, stopPolling])
const isRunning = sessionId !== null && pollData !== null && !isTerminalStatus(pollData.status)
const isTerminal = pollData !== null && isTerminalStatus(pollData.status)
return {
sessionId,
pollData,
resolvedIds,
error,
isUnauthorized: unauthorizedError,
start,
cancel,
isRunning,
isTerminal,
}
}