# Story 3.3: Availability Calendar Display ## Epic Reference **Epic 3:** Booking & Consultation System ## User Story As a **client**, I want **to see a calendar with available time slots**, So that **I can choose a convenient time for my consultation**. ## Story Context ### Existing System Integration - **Integrates with:** working_hours, blocked_times, consultations tables - **Technology:** Livewire Volt, JavaScript calendar library - **Follows pattern:** Real-time availability checking - **Touch points:** Booking submission flow ## Acceptance Criteria ### Calendar Display - [ ] Monthly calendar view showing available dates - [ ] Visual distinction for date states: - Available (has open slots) - Partially available (some slots taken) - Unavailable (fully booked or blocked) - Past dates (grayed out) - [ ] Navigate between months - [ ] Current month shown by default ### Time Slot Display - [ ] Clicking a date shows available time slots - [ ] 1-hour slots (45min consultation + 15min buffer) - [ ] Clear indication of slot availability - [ ] Unavailable reasons (optional): - Already booked - Outside working hours - Blocked by admin ### Real-time Updates - [ ] Availability checked on date selection - [ ] Prevent double-booking (race condition handling) - [ ] Refresh availability when navigating months ### Responsive Design - [ ] Mobile-friendly calendar - [ ] Touch-friendly slot selection - [ ] Proper RTL support for Arabic ### Quality Requirements - [ ] Fast loading (eager load data) - [ ] Language-appropriate date formatting - [ ] Accessible (keyboard navigation) - [ ] Tests for availability logic ## Technical Notes ### Availability Service ```php startOfMonth(); $endOfMonth = $startOfMonth->copy()->endOfMonth(); $availability = []; $current = $startOfMonth->copy(); while ($current->lte($endOfMonth)) { $availability[$current->format('Y-m-d')] = $this->getDateStatus($current); $current->addDay(); } return $availability; } public function getDateStatus(Carbon $date): string { // Past date if ($date->lt(today())) { return 'past'; } // Check if fully blocked if ($this->isDateFullyBlocked($date)) { return 'blocked'; } // Check working hours $workingHour = WorkingHour::where('day_of_week', $date->dayOfWeek) ->where('is_active', true) ->first(); if (!$workingHour) { return 'closed'; } // Get available slots $availableSlots = $this->getAvailableSlots($date); if (empty($availableSlots)) { return 'full'; } $totalSlots = count($workingHour->getSlots(60)); if (count($availableSlots) < $totalSlots) { return 'partial'; } return 'available'; } public function getAvailableSlots(Carbon $date): array { $workingHour = WorkingHour::where('day_of_week', $date->dayOfWeek) ->where('is_active', true) ->first(); if (!$workingHour) { return []; } // All possible slots $allSlots = $workingHour->getSlots(60); // Booked slots $bookedSlots = Consultation::where('scheduled_date', $date->toDateString()) ->whereIn('status', ['pending', 'approved']) ->pluck('scheduled_time') ->map(fn($t) => Carbon::parse($t)->format('H:i')) ->toArray(); // Blocked slots $blockedSlots = $this->getBlockedSlots($date); return array_values(array_diff($allSlots, $bookedSlots, $blockedSlots)); } private function getBlockedSlots(Carbon $date): array { $blockedTimes = BlockedTime::where('block_date', $date->toDateString())->get(); $blockedSlots = []; foreach ($blockedTimes as $blocked) { if ($blocked->isAllDay()) { // Block all slots $workingHour = WorkingHour::where('day_of_week', $date->dayOfWeek)->first(); return $workingHour ? $workingHour->getSlots(60) : []; } // Calculate blocked slots from time range $start = Carbon::parse($blocked->start_time); $end = Carbon::parse($blocked->end_time); $current = $start->copy(); while ($current->lt($end)) { $blockedSlots[] = $current->format('H:i'); $current->addMinutes(60); } } return array_unique($blockedSlots); } private function isDateFullyBlocked(Carbon $date): bool { return BlockedTime::where('block_date', $date->toDateString()) ->whereNull('start_time') ->exists(); } } ``` ### Volt Component ```php year = now()->year; $this->month = now()->month; $this->loadMonthAvailability(); } public function loadMonthAvailability(): void { $service = app(AvailabilityService::class); $this->monthAvailability = $service->getMonthAvailability($this->year, $this->month); } public function previousMonth(): void { $date = Carbon::create($this->year, $this->month, 1)->subMonth(); $this->year = $date->year; $this->month = $date->month; $this->selectedDate = null; $this->availableSlots = []; $this->loadMonthAvailability(); } public function nextMonth(): void { $date = Carbon::create($this->year, $this->month, 1)->addMonth(); $this->year = $date->year; $this->month = $date->month; $this->selectedDate = null; $this->availableSlots = []; $this->loadMonthAvailability(); } public function selectDate(string $date): void { $status = $this->monthAvailability[$date] ?? 'unavailable'; if (in_array($status, ['available', 'partial'])) { $this->selectedDate = $date; $this->loadAvailableSlots(); } } public function loadAvailableSlots(): void { if (!$this->selectedDate) { $this->availableSlots = []; return; } $service = app(AvailabilityService::class); $this->availableSlots = $service->getAvailableSlots( Carbon::parse($this->selectedDate) ); } public function with(): array { return [ 'monthName' => Carbon::create($this->year, $this->month, 1)->translatedFormat('F Y'), 'calendarDays' => $this->buildCalendarDays(), ]; } private function buildCalendarDays(): array { $firstDay = Carbon::create($this->year, $this->month, 1); $lastDay = $firstDay->copy()->endOfMonth(); // Pad start of month $startPadding = $firstDay->dayOfWeek; $days = array_fill(0, $startPadding, null); // Fill month days $current = $firstDay->copy(); while ($current->lte($lastDay)) { $dateStr = $current->format('Y-m-d'); $days[] = [ 'date' => $dateStr, 'day' => $current->day, 'status' => $this->monthAvailability[$dateStr] ?? 'unavailable', ]; $current->addDay(); } return $days; } }; ``` ### Blade Template ```blade
{{ __('booking.no_slots_available') }}
@endif