From 1cf94a1a2c3e1a5789e949699134cfe4587bc52c Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Wed, 12 Aug 2026 19:56:16 +0200 Subject: [PATCH] feat: replace mock availability generator with real schedule computation --- src/data/availability.test.ts | 100 +++++++++++++++++++++++++++------- src/data/availability.ts | 86 ++++++++++++++++++++++------- 2 files changed, 148 insertions(+), 38 deletions(-) diff --git a/src/data/availability.test.ts b/src/data/availability.test.ts index 645b2db..3c38ca6 100644 --- a/src/data/availability.test.ts +++ b/src/data/availability.test.ts @@ -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 { + 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([[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() }) }) diff --git a/src/data/availability.ts b/src/data/availability.ts index 792e08a..070eedb 100644 --- a/src/data/availability.ts +++ b/src/data/availability.ts @@ -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, + 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 } }