feat: add useAvailability hook for real schedule/booking data
This commit is contained in:
81
src/hooks/useAvailability.test.ts
Normal file
81
src/hooks/useAvailability.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const templateSingleMock = vi.fn()
|
||||
const exceptionsSelectMock = vi.fn()
|
||||
const bookedSlotsEqMock = vi.fn()
|
||||
const bookedSlotsGteMock = vi.fn()
|
||||
const bookedSlotsLteMock = vi.fn()
|
||||
const fromMock = vi.fn()
|
||||
|
||||
vi.mock('../lib/supabase', () => ({
|
||||
supabase: { from: (table: string) => fromMock(table) },
|
||||
}))
|
||||
|
||||
import useAvailability from './useAvailability'
|
||||
|
||||
const TEMPLATE = {
|
||||
id: 'default',
|
||||
working_days: [2, 3, 4, 5, 6],
|
||||
start_time: '09:00:00',
|
||||
end_time: '13:00:00',
|
||||
slot_minutes: 60,
|
||||
}
|
||||
|
||||
describe('useAvailability', () => {
|
||||
beforeEach(() => {
|
||||
templateSingleMock.mockReset().mockResolvedValue({ data: TEMPLATE, error: null })
|
||||
exceptionsSelectMock.mockReset().mockResolvedValue({ data: [], error: null })
|
||||
bookedSlotsLteMock.mockReset().mockResolvedValue({ data: [], error: null })
|
||||
bookedSlotsGteMock.mockReset().mockReturnValue({ lte: bookedSlotsLteMock })
|
||||
bookedSlotsEqMock.mockReset().mockResolvedValue({ data: [], error: null })
|
||||
fromMock.mockReset()
|
||||
|
||||
fromMock.mockImplementation((table: string) => {
|
||||
if (table === 'schedule_template') {
|
||||
return { select: () => ({ eq: () => ({ single: templateSingleMock }) }) }
|
||||
}
|
||||
if (table === 'schedule_exceptions') {
|
||||
return { select: () => exceptionsSelectMock() }
|
||||
}
|
||||
if (table === 'booked_slots') {
|
||||
return { select: () => ({ eq: bookedSlotsEqMock, gte: bookedSlotsGteMock }) }
|
||||
}
|
||||
throw new Error(`unexpected table: ${table}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches the template and exceptions once on mount', async () => {
|
||||
const { result } = renderHook(() => useAvailability())
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(templateSingleMock).toHaveBeenCalled()
|
||||
expect(exceptionsSelectMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('getSlotsForDate fetches booked_slots for that date and returns computed slots', async () => {
|
||||
const day = new Date('2026-08-11T00:00:00').getDay()
|
||||
templateSingleMock.mockResolvedValue({ data: { ...TEMPLATE, working_days: [day] }, error: null })
|
||||
bookedSlotsEqMock.mockResolvedValue({ data: [{ date: '2026-08-11', time: '10:00 AM' }], error: null })
|
||||
|
||||
const { result } = renderHook(() => useAvailability())
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
const slots = await result.current.getSlotsForDate('2026-08-11')
|
||||
expect(bookedSlotsEqMock).toHaveBeenCalledWith('date', '2026-08-11')
|
||||
expect(slots.find((s) => s.time === '10:00 AM')?.available).toBe(false)
|
||||
expect(slots.find((s) => s.time === '9:00 AM')?.available).toBe(true)
|
||||
})
|
||||
|
||||
it('findNextAvailableDate fetches a date range and returns the first available date', async () => {
|
||||
const nextDate = '2026-08-12'
|
||||
const nextDay = new Date(`${nextDate}T00:00:00`).getDay()
|
||||
templateSingleMock.mockResolvedValue({ data: { ...TEMPLATE, working_days: [nextDay] }, error: null })
|
||||
|
||||
const { result } = renderHook(() => useAvailability())
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
const next = await result.current.findNextAvailableDate('2026-08-10', 14)
|
||||
expect(bookedSlotsGteMock).toHaveBeenCalledWith('date', '2026-08-10')
|
||||
expect(next).toBe(nextDate)
|
||||
})
|
||||
})
|
||||
91
src/hooks/useAvailability.ts
Normal file
91
src/hooks/useAvailability.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import {
|
||||
computeAvailableSlots,
|
||||
getNextAvailableDateFromSlots,
|
||||
type TimeSlot,
|
||||
type ScheduleTemplate,
|
||||
type ScheduleException,
|
||||
type BookedSlot,
|
||||
} from '../data/availability'
|
||||
|
||||
interface UseAvailabilityResult {
|
||||
loading: boolean
|
||||
error: string
|
||||
getSlotsForDate: (date: string) => Promise<TimeSlot[]>
|
||||
findNextAvailableDate: (fromDate: string, searchWindowDays?: number) => Promise<string | null>
|
||||
}
|
||||
|
||||
export default function useAvailability(): UseAvailabilityResult {
|
||||
const [template, setTemplate] = useState<ScheduleTemplate | null>(null)
|
||||
const [exceptions, setExceptions] = useState<ScheduleException[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [templateResult, exceptionsResult] = await Promise.all([
|
||||
supabase.from('schedule_template').select('*').eq('id', 'default').single(),
|
||||
supabase.from('schedule_exceptions').select('*'),
|
||||
])
|
||||
if (templateResult.error) {
|
||||
setError(templateResult.error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (exceptionsResult.error) {
|
||||
setError(exceptionsResult.error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setTemplate(templateResult.data as ScheduleTemplate)
|
||||
setExceptions((exceptionsResult.data ?? []) as ScheduleException[])
|
||||
setLoading(false)
|
||||
}
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const getSlotsForDate = useCallback(
|
||||
async (date: string): Promise<TimeSlot[]> => {
|
||||
if (!template) return []
|
||||
const { data, error: fetchError } = await supabase.from('booked_slots').select('*').eq('date', date)
|
||||
if (fetchError) {
|
||||
setError(fetchError.message)
|
||||
return []
|
||||
}
|
||||
return computeAvailableSlots(template, exceptions, (data ?? []) as BookedSlot[], date)
|
||||
},
|
||||
[template, exceptions],
|
||||
)
|
||||
|
||||
const findNextAvailableDate = useCallback(
|
||||
async (fromDate: string, searchWindowDays = 14): Promise<string | null> => {
|
||||
if (!template) return null
|
||||
const start = new Date(`${fromDate}T00:00:00`)
|
||||
const end = new Date(start)
|
||||
end.setDate(start.getDate() + searchWindowDays)
|
||||
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('booked_slots')
|
||||
.select('*')
|
||||
.gte('date', fromDate)
|
||||
.lte('date', end.toISOString().slice(0, 10))
|
||||
if (fetchError) {
|
||||
setError(fetchError.message)
|
||||
return null
|
||||
}
|
||||
|
||||
const bookedByDate = new Map<string, BookedSlot[]>()
|
||||
for (const row of (data ?? []) as BookedSlot[]) {
|
||||
const list = bookedByDate.get(row.date) ?? []
|
||||
list.push(row)
|
||||
bookedByDate.set(row.date, list)
|
||||
}
|
||||
|
||||
return getNextAvailableDateFromSlots(template, exceptions, bookedByDate, fromDate, searchWindowDays)
|
||||
},
|
||||
[template, exceptions],
|
||||
)
|
||||
|
||||
return { loading, error, getSlotsForDate, findNextAvailableDate }
|
||||
}
|
||||
Reference in New Issue
Block a user