feat: replace mock availability generator with real schedule computation
This commit is contained in:
@@ -1,29 +1,91 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getSlotsForDate, getNextAvailableDate } from './availability'
|
||||
import { computeAvailableSlots, getNextAvailableDateFromSlots, type ScheduleTemplate, type ScheduleException, type BookedSlot } from './availability'
|
||||
|
||||
describe('availability data', () => {
|
||||
it('returns a list of time slots for a given date', () => {
|
||||
const slots = getSlotsForDate('2026-08-10')
|
||||
expect(slots.length).toBeGreaterThan(0)
|
||||
for (const s of slots) {
|
||||
expect(s.date).toBe('2026-08-10')
|
||||
expect(typeof s.time).toBe('string')
|
||||
expect(typeof s.available).toBe('boolean')
|
||||
}
|
||||
function templateFor(date: string, overrides: Partial<ScheduleTemplate> = {}): ScheduleTemplate {
|
||||
const day = new Date(`${date}T00:00:00`).getDay()
|
||||
return {
|
||||
id: 'default',
|
||||
working_days: [day],
|
||||
start_time: '09:00:00',
|
||||
end_time: '13:00:00',
|
||||
slot_minutes: 60,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('computeAvailableSlots', () => {
|
||||
it('generates slots from start_time to end_time stepped by slot_minutes on a working day', () => {
|
||||
const date = '2026-08-11'
|
||||
const template = templateFor(date)
|
||||
const slots = computeAvailableSlots(template, [], [], date)
|
||||
expect(slots).toEqual([
|
||||
{ date, time: '9:00 AM', available: true },
|
||||
{ date, time: '10:00 AM', available: true },
|
||||
{ date, time: '11:00 AM', available: true },
|
||||
{ date, time: '12:00 PM', available: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('is deterministic for the same date', () => {
|
||||
expect(getSlotsForDate('2026-08-10')).toEqual(getSlotsForDate('2026-08-10'))
|
||||
it('returns no slots when the date is a closed exception', () => {
|
||||
const date = '2026-08-11'
|
||||
const template = templateFor(date)
|
||||
const exceptions: ScheduleException[] = [{ date, closed: true, note: 'Congé' }]
|
||||
expect(computeAvailableSlots(template, exceptions, [], date)).toEqual([])
|
||||
})
|
||||
|
||||
it('finds the next available date within 14 days when none is available immediately', () => {
|
||||
// Sundays (day 0) are fully booked in the mock generator
|
||||
const next = getNextAvailableDate('2026-08-09') // a Sunday
|
||||
expect(next).not.toBeNull()
|
||||
expect(next).not.toBe('2026-08-09')
|
||||
it('returns no slots when the day of week is not a working day', () => {
|
||||
const date = '2026-08-11'
|
||||
const day = new Date(`${date}T00:00:00`).getDay()
|
||||
const otherDay = (day + 1) % 7
|
||||
const template = templateFor(date, { working_days: [otherDay] })
|
||||
expect(computeAvailableSlots(template, [], [], date)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns null if no available date is found within the search window', () => {
|
||||
expect(getNextAvailableDate('1999-01-01', 0)).toBeNull()
|
||||
it('marks a slot unavailable when it already appears in booked_slots for that date', () => {
|
||||
const date = '2026-08-11'
|
||||
const template = templateFor(date)
|
||||
const bookedSlots: BookedSlot[] = [{ date, time: '10:00 AM' }]
|
||||
const slots = computeAvailableSlots(template, [], bookedSlots, date)
|
||||
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('ignores booked_slots rows for a different date', () => {
|
||||
const date = '2026-08-11'
|
||||
const template = templateFor(date)
|
||||
const bookedSlots: BookedSlot[] = [{ date: '2026-08-12', time: '9:00 AM' }]
|
||||
const slots = computeAvailableSlots(template, [], bookedSlots, date)
|
||||
expect(slots.every((s) => s.available)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextAvailableDateFromSlots', () => {
|
||||
it('finds the first date within the search window that has an available slot', () => {
|
||||
const fromDate = '2026-08-10'
|
||||
const nextDate = '2026-08-12'
|
||||
const nextDay = new Date(`${nextDate}T00:00:00`).getDay()
|
||||
const template: ScheduleTemplate = {
|
||||
id: 'default', working_days: [nextDay], start_time: '09:00:00', end_time: '10:00:00', slot_minutes: 60,
|
||||
}
|
||||
const result = getNextAvailableDateFromSlots(template, [], new Map(), fromDate, 14)
|
||||
expect(result).toBe(nextDate)
|
||||
})
|
||||
|
||||
it('returns null when no date in the window has an available slot', () => {
|
||||
const template: ScheduleTemplate = {
|
||||
id: 'default', working_days: [], start_time: '09:00:00', end_time: '10:00:00', slot_minutes: 60,
|
||||
}
|
||||
expect(getNextAvailableDateFromSlots(template, [], new Map(), '2026-08-10', 14)).toBeNull()
|
||||
})
|
||||
|
||||
it('respects a fully booked date by skipping it', () => {
|
||||
const nextDate = '2026-08-11'
|
||||
const nextDay = new Date(`${nextDate}T00:00:00`).getDay()
|
||||
const template: ScheduleTemplate = {
|
||||
id: 'default', working_days: [nextDay], start_time: '09:00:00', end_time: '10:00:00', slot_minutes: 60,
|
||||
}
|
||||
const bookedByDate = new Map<string, BookedSlot[]>([[nextDate, [{ date: nextDate, time: '9:00 AM' }]]])
|
||||
// With a 1-hour window (09:00-10:00, 60min slots) there's only one slot, and it's booked.
|
||||
expect(getNextAvailableDateFromSlots(template, [], bookedByDate, '2026-08-10', 1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,42 +1,90 @@
|
||||
export interface TimeSlot {
|
||||
date: string // ISO date, e.g. "2026-08-10"
|
||||
time: string // e.g. "10:00 AM"
|
||||
date: string
|
||||
time: string
|
||||
available: boolean
|
||||
}
|
||||
|
||||
const ALL_TIMES = ['9:00 AM', '11:00 AM', '1:00 PM', '3:00 PM', '5:00 PM']
|
||||
|
||||
function seedFromDate(date: string): number {
|
||||
let seed = 0
|
||||
for (let i = 0; i < date.length; i++) seed += date.charCodeAt(i)
|
||||
return seed
|
||||
export interface ScheduleTemplate {
|
||||
id: string
|
||||
working_days: number[]
|
||||
start_time: string
|
||||
end_time: string
|
||||
slot_minutes: number
|
||||
}
|
||||
|
||||
export function getSlotsForDate(date: string): TimeSlot[] {
|
||||
export interface ScheduleException {
|
||||
date: string
|
||||
closed: boolean
|
||||
note: string | null
|
||||
}
|
||||
|
||||
export interface BookedSlot {
|
||||
date: string
|
||||
time: string
|
||||
}
|
||||
|
||||
function parseTimeToMinutes(time: string): number {
|
||||
const [hours, minutes] = time.split(':').map(Number)
|
||||
return hours * 60 + minutes
|
||||
}
|
||||
|
||||
function formatMinutesAsTime(totalMinutes: number): string {
|
||||
const hour24 = Math.floor(totalMinutes / 60)
|
||||
const minute = totalMinutes % 60
|
||||
const period = hour24 >= 12 ? 'PM' : 'AM'
|
||||
const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12
|
||||
return `${hour12}:${minute.toString().padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
export function computeAvailableSlots(
|
||||
template: ScheduleTemplate,
|
||||
exceptions: ScheduleException[],
|
||||
bookedSlots: BookedSlot[],
|
||||
date: string,
|
||||
): TimeSlot[] {
|
||||
const exception = exceptions.find((e) => e.date === date)
|
||||
if (exception?.closed) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parsed = new Date(`${date}T00:00:00`)
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return []
|
||||
}
|
||||
|
||||
const day = parsed.getDay()
|
||||
if (day === 0) {
|
||||
// Sundays: closed, no slots at all
|
||||
if (!template.working_days.includes(day)) {
|
||||
return []
|
||||
}
|
||||
const seed = seedFromDate(date)
|
||||
return ALL_TIMES.map((time, i) => ({
|
||||
date,
|
||||
time,
|
||||
available: (seed + i) % 3 !== 0,
|
||||
}))
|
||||
|
||||
const startMinutes = parseTimeToMinutes(template.start_time)
|
||||
const endMinutes = parseTimeToMinutes(template.end_time)
|
||||
const bookedTimes = new Set(
|
||||
bookedSlots.filter((b) => b.date === date).map((b) => b.time),
|
||||
)
|
||||
|
||||
const slots: TimeSlot[] = []
|
||||
for (let m = startMinutes; m < endMinutes; m += template.slot_minutes) {
|
||||
const time = formatMinutesAsTime(m)
|
||||
slots.push({ date, time, available: !bookedTimes.has(time) })
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
export function getNextAvailableDate(fromDate: string, searchWindowDays = 14): string | null {
|
||||
export function getNextAvailableDateFromSlots(
|
||||
template: ScheduleTemplate,
|
||||
exceptions: ScheduleException[],
|
||||
bookedSlotsByDate: Map<string, BookedSlot[]>,
|
||||
fromDate: string,
|
||||
searchWindowDays = 14,
|
||||
): string | null {
|
||||
const start = new Date(`${fromDate}T00:00:00`)
|
||||
for (let i = 1; i <= searchWindowDays; i++) {
|
||||
const candidate = new Date(start)
|
||||
candidate.setDate(start.getDate() + i)
|
||||
const iso = candidate.toISOString().slice(0, 10)
|
||||
if (getSlotsForDate(iso).some((s) => s.available)) {
|
||||
const slots = computeAvailableSlots(template, exceptions, bookedSlotsByDate.get(iso) ?? [], iso)
|
||||
if (slots.some((s) => s.available)) {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user