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 { describe, it, expect } from 'vitest'
|
||||||
import { getSlotsForDate, getNextAvailableDate } from './availability'
|
import { computeAvailableSlots, getNextAvailableDateFromSlots, type ScheduleTemplate, type ScheduleException, type BookedSlot } from './availability'
|
||||||
|
|
||||||
describe('availability data', () => {
|
function templateFor(date: string, overrides: Partial<ScheduleTemplate> = {}): ScheduleTemplate {
|
||||||
it('returns a list of time slots for a given date', () => {
|
const day = new Date(`${date}T00:00:00`).getDay()
|
||||||
const slots = getSlotsForDate('2026-08-10')
|
return {
|
||||||
expect(slots.length).toBeGreaterThan(0)
|
id: 'default',
|
||||||
for (const s of slots) {
|
working_days: [day],
|
||||||
expect(s.date).toBe('2026-08-10')
|
start_time: '09:00:00',
|
||||||
expect(typeof s.time).toBe('string')
|
end_time: '13:00:00',
|
||||||
expect(typeof s.available).toBe('boolean')
|
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', () => {
|
it('returns no slots when the date is a closed exception', () => {
|
||||||
expect(getSlotsForDate('2026-08-10')).toEqual(getSlotsForDate('2026-08-10'))
|
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', () => {
|
it('returns no slots when the day of week is not a working day', () => {
|
||||||
// Sundays (day 0) are fully booked in the mock generator
|
const date = '2026-08-11'
|
||||||
const next = getNextAvailableDate('2026-08-09') // a Sunday
|
const day = new Date(`${date}T00:00:00`).getDay()
|
||||||
expect(next).not.toBeNull()
|
const otherDay = (day + 1) % 7
|
||||||
expect(next).not.toBe('2026-08-09')
|
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', () => {
|
it('marks a slot unavailable when it already appears in booked_slots for that date', () => {
|
||||||
expect(getNextAvailableDate('1999-01-01', 0)).toBeNull()
|
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 {
|
export interface TimeSlot {
|
||||||
date: string // ISO date, e.g. "2026-08-10"
|
date: string
|
||||||
time: string // e.g. "10:00 AM"
|
time: string
|
||||||
available: boolean
|
available: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_TIMES = ['9:00 AM', '11:00 AM', '1:00 PM', '3:00 PM', '5:00 PM']
|
export interface ScheduleTemplate {
|
||||||
|
id: string
|
||||||
function seedFromDate(date: string): number {
|
working_days: number[]
|
||||||
let seed = 0
|
start_time: string
|
||||||
for (let i = 0; i < date.length; i++) seed += date.charCodeAt(i)
|
end_time: string
|
||||||
return seed
|
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`)
|
const parsed = new Date(`${date}T00:00:00`)
|
||||||
if (isNaN(parsed.getTime())) {
|
if (isNaN(parsed.getTime())) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const day = parsed.getDay()
|
const day = parsed.getDay()
|
||||||
if (day === 0) {
|
if (!template.working_days.includes(day)) {
|
||||||
// Sundays: closed, no slots at all
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const seed = seedFromDate(date)
|
|
||||||
return ALL_TIMES.map((time, i) => ({
|
const startMinutes = parseTimeToMinutes(template.start_time)
|
||||||
date,
|
const endMinutes = parseTimeToMinutes(template.end_time)
|
||||||
time,
|
const bookedTimes = new Set(
|
||||||
available: (seed + i) % 3 !== 0,
|
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`)
|
const start = new Date(`${fromDate}T00:00:00`)
|
||||||
for (let i = 1; i <= searchWindowDays; i++) {
|
for (let i = 1; i <= searchWindowDays; i++) {
|
||||||
const candidate = new Date(start)
|
const candidate = new Date(start)
|
||||||
candidate.setDate(start.getDate() + i)
|
candidate.setDate(start.getDate() + i)
|
||||||
const iso = candidate.toISOString().slice(0, 10)
|
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
|
return iso
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user