feat: replace mock availability generator with real schedule computation

This commit is contained in:
belviskhoremk
2026-08-12 19:56:16 +02:00
parent 631ec0b567
commit 1cf94a1a2c
2 changed files with 148 additions and 38 deletions

View File

@@ -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()
})
})

View File

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