feat: add useAvailability hook for real schedule/booking data

This commit is contained in:
belviskhoremk
2026-08-12 20:17:24 +02:00
parent a0e8a2ca60
commit 375dc5eed8
2 changed files with 172 additions and 0 deletions

View 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)
})
})

View 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 }
}