# 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 ### Navigation Constraints - [ ] Prevent navigating to months before current month (all dates would be "past") - [ ] Optionally limit future navigation (e.g., max 3 months ahead) - configurable ### 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 ### File Structure Create the following files: - `app/Services/AvailabilityService.php` - Core availability logic - `resources/views/livewire/availability-calendar.blade.php` - Volt component - `lang/en/calendar.php` - English calendar translations - `lang/ar/calendar.php` - Arabic calendar translations - `lang/en/booking.php` - English booking translations (if not exists) - `lang/ar/booking.php` - Arabic booking translations (if not exists) - `tests/Unit/Services/AvailabilityServiceTest.php` - Unit tests - `tests/Feature/Livewire/AvailabilityCalendarTest.php` - Feature tests ### 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(); // Prevent navigating to past months if ($date->lt(now()->startOfMonth())) { return; } $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
{{ $monthName }}
@foreach(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as $day)
{{ __("calendar.{$day}") }}
@endforeach
@foreach($calendarDays as $dayData) @if($dayData === null)
@else @endif @endforeach
@if($selectedDate)
{{ __('booking.available_times') }} - {{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('d M Y') }} @if(count($availableSlots) > 0)
@foreach($availableSlots as $slot) @endforeach
@else

{{ __('booking.no_slots_available') }}

@endif
@endif
{{ __('booking.available') }}
{{ __('booking.partial') }}
{{ __('booking.unavailable') }}
``` ### RTL Support Implementation The calendar requires specific RTL handling for Arabic users: 1. **Navigation arrows**: Already handled with locale-aware chevron direction (see Blade template) 2. **Day headers order**: Use locale-aware day ordering: ```php // In Volt component private function getDayHeaders(): array { $days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // Arabic calendar traditionally starts on Saturday if (app()->getLocale() === 'ar') { // Reorder: Sat, Sun, Mon, Tue, Wed, Thu, Fri array_unshift($days, array_pop($days)); } return $days; } ``` 3. **Calendar grid direction**: Apply RTL class to the grid container: ```blade
``` 4. **Required translation keys** (add to `lang/ar/calendar.php` and `lang/en/calendar.php`): ```php // lang/en/calendar.php return [ 'Sun' => 'Sun', 'Mon' => 'Mon', 'Tue' => 'Tue', 'Wed' => 'Wed', 'Thu' => 'Thu', 'Fri' => 'Fri', 'Sat' => 'Sat', ]; // lang/ar/calendar.php return [ 'Sun' => 'أحد', 'Mon' => 'إثن', 'Tue' => 'ثلا', 'Wed' => 'أرب', 'Thu' => 'خمي', 'Fri' => 'جمع', 'Sat' => 'سبت', ]; ``` 5. **Booking translation keys** (add to `lang/*/booking.php`): ```php // lang/en/booking.php return [ 'available' => 'Available', 'partial' => 'Partial', 'unavailable' => 'Unavailable', 'available_times' => 'Available Times', 'no_slots_available' => 'No slots available for this date.', ]; // lang/ar/booking.php return [ 'available' => 'متاح', 'partial' => 'متاح جزئياً', 'unavailable' => 'غير متاح', 'available_times' => 'الأوقات المتاحة', 'no_slots_available' => 'لا توجد مواعيد متاحة لهذا التاريخ.', ]; ``` ## Definition of Done - [ ] Calendar displays current month - [ ] Can navigate between months - [ ] Available dates clearly indicated - [ ] Clicking date shows time slots - [ ] Time slots in 1-hour increments - [ ] Prevents selecting unavailable dates/times - [ ] Real-time availability updates - [ ] Mobile responsive - [ ] RTL support for Arabic - [ ] Tests for availability logic - [ ] Code formatted with Pint ## Testing Scenarios ### AvailabilityService Unit Tests Create `tests/Unit/Services/AvailabilityServiceTest.php`: ```php create([ 'day_of_week' => $day, 'start_time' => '09:00', 'end_time' => '17:00', 'is_active' => true, ]); } }); describe('getDateStatus', function () { it('returns "past" for yesterday', function () { $service = new AvailabilityService(); $yesterday = Carbon::yesterday(); expect($service->getDateStatus($yesterday))->toBe('past'); }); it('returns "closed" for non-working days (weekends)', function () { $service = new AvailabilityService(); $sunday = Carbon::now()->next(Carbon::SUNDAY); expect($service->getDateStatus($sunday))->toBe('closed'); }); it('returns "blocked" for fully blocked date', function () { $monday = Carbon::now()->next(Carbon::MONDAY); BlockedTime::factory()->create([ 'block_date' => $monday->toDateString(), 'start_time' => null, // All day block 'end_time' => null, ]); $service = new AvailabilityService(); expect($service->getDateStatus($monday))->toBe('blocked'); }); it('returns "full" when all slots are booked', function () { $monday = Carbon::now()->next(Carbon::MONDAY); $slots = ['09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00']; foreach ($slots as $slot) { Consultation::factory()->create([ 'scheduled_date' => $monday->toDateString(), 'scheduled_time' => $slot, 'status' => 'approved', ]); } $service = new AvailabilityService(); expect($service->getDateStatus($monday))->toBe('full'); }); it('returns "partial" when some slots are booked', function () { $monday = Carbon::now()->next(Carbon::MONDAY); Consultation::factory()->create([ 'scheduled_date' => $monday->toDateString(), 'scheduled_time' => '10:00', 'status' => 'approved', ]); $service = new AvailabilityService(); expect($service->getDateStatus($monday))->toBe('partial'); }); it('returns "available" for working day with no bookings', function () { $monday = Carbon::now()->next(Carbon::MONDAY); $service = new AvailabilityService(); expect($service->getDateStatus($monday))->toBe('available'); }); }); describe('getAvailableSlots', function () { it('returns empty array for non-working days', function () { $service = new AvailabilityService(); $sunday = Carbon::now()->next(Carbon::SUNDAY); expect($service->getAvailableSlots($sunday))->toBe([]); }); it('excludes booked consultation times', function () { $monday = Carbon::now()->next(Carbon::MONDAY); Consultation::factory()->create([ 'scheduled_date' => $monday->toDateString(), 'scheduled_time' => '10:00', 'status' => 'pending', ]); $service = new AvailabilityService(); $slots = $service->getAvailableSlots($monday); expect($slots)->not->toContain('10:00'); }); it('excludes blocked time ranges', function () { $monday = Carbon::now()->next(Carbon::MONDAY); BlockedTime::factory()->create([ 'block_date' => $monday->toDateString(), 'start_time' => '14:00', 'end_time' => '16:00', ]); $service = new AvailabilityService(); $slots = $service->getAvailableSlots($monday); expect($slots)->not->toContain('14:00'); expect($slots)->not->toContain('15:00'); }); it('includes pending and approved consultations as booked', function () { $monday = Carbon::now()->next(Carbon::MONDAY); Consultation::factory()->create([ 'scheduled_date' => $monday->toDateString(), 'scheduled_time' => '09:00', 'status' => 'pending', ]); Consultation::factory()->create([ 'scheduled_date' => $monday->toDateString(), 'scheduled_time' => '10:00', 'status' => 'approved', ]); // Cancelled should NOT block the slot Consultation::factory()->create([ 'scheduled_date' => $monday->toDateString(), 'scheduled_time' => '11:00', 'status' => 'cancelled', ]); $service = new AvailabilityService(); $slots = $service->getAvailableSlots($monday); expect($slots)->not->toContain('09:00'); expect($slots)->not->toContain('10:00'); expect($slots)->toContain('11:00'); // Cancelled slot is available }); }); describe('getMonthAvailability', function () { it('returns status for every day in the month', function () { $service = new AvailabilityService(); $availability = $service->getMonthAvailability(2025, 1); expect($availability)->toHaveCount(31); // January has 31 days }); it('handles year rollover correctly (December to January)', function () { $service = new AvailabilityService(); $december = $service->getMonthAvailability(2024, 12); $january = $service->getMonthAvailability(2025, 1); expect($december)->toHaveKey('2024-12-31'); expect($january)->toHaveKey('2025-01-01'); }); }); ``` ### Volt Component Feature Tests Create `tests/Feature/Livewire/AvailabilityCalendarTest.php`: ```php create([ 'day_of_week' => $day, 'start_time' => '09:00', 'end_time' => '17:00', 'is_active' => true, ]); } }); it('displays current month by default', function () { $currentMonth = now()->translatedFormat('F Y'); Volt::test('availability-calendar') ->assertSee($currentMonth); }); it('navigates to next month', function () { $nextMonth = now()->addMonth()->translatedFormat('F Y'); Volt::test('availability-calendar') ->call('nextMonth') ->assertSee($nextMonth); }); it('navigates to previous month', function () { $prevMonth = now()->subMonth()->translatedFormat('F Y'); Volt::test('availability-calendar') ->call('previousMonth') ->assertSee($prevMonth); }); it('handles year rollover when navigating months', function () { // Start in December Carbon::setTestNow(Carbon::create(2024, 12, 15)); Volt::test('availability-calendar') ->assertSet('year', 2024) ->assertSet('month', 12) ->call('nextMonth') ->assertSet('year', 2025) ->assertSet('month', 1); Carbon::setTestNow(); }); it('loads available slots when date is selected', function () { $monday = Carbon::now()->next(Carbon::MONDAY)->format('Y-m-d'); Volt::test('availability-calendar') ->call('selectDate', $monday) ->assertSet('selectedDate', $monday) ->assertNotEmpty('availableSlots'); }); it('does not select unavailable dates', function () { $sunday = Carbon::now()->next(Carbon::SUNDAY)->format('Y-m-d'); Volt::test('availability-calendar') ->call('selectDate', $sunday) ->assertSet('selectedDate', null) ->assertSet('availableSlots', []); }); it('clears selection when navigating months', function () { $monday = Carbon::now()->next(Carbon::MONDAY)->format('Y-m-d'); Volt::test('availability-calendar') ->call('selectDate', $monday) ->assertSet('selectedDate', $monday) ->call('nextMonth') ->assertSet('selectedDate', null) ->assertSet('availableSlots', []); }); it('displays RTL layout for Arabic locale', function () { app()->setLocale('ar'); Volt::test('availability-calendar') ->assertSeeHtml('dir="rtl"'); }); it('prevents navigating to past months', function () { Volt::test('availability-calendar') ->assertSet('year', now()->year) ->assertSet('month', now()->month) ->call('previousMonth') ->assertSet('year', now()->year) // Should not change ->assertSet('month', now()->month); // Should not change }); ``` ### Browser Tests (Optional - Story 3.4+ integration) ```php create([ 'day_of_week' => Carbon::MONDAY, 'is_active' => true, ]); $user = User::factory()->create(); $page = visit('/book') ->actingAs($user) ->assertSee('Available') ->click('[data-available-date]') ->assertSee('Available Times') ->click('[data-time-slot="09:00"]') ->assertNoJavascriptErrors(); }); ``` ## Dependencies - **Story 3.1:** Working hours (defines available time) - **MUST be completed first** - **Story 3.2:** Blocked times (removes availability) - **MUST be completed first** - **Story 3.4:** Booking submission (consumes selected slot) - developed after this story ### Parent Component Dependency This calendar component is designed to be embedded within a parent booking component (Story 3.4). The `$parent.selectSlot()` call expects the parent to have a `selectSlot(string $date, string $time)` method. For standalone testing, create a wrapper component or mock the parent interaction. ## Risk Assessment - **Primary Risk:** Race condition on slot selection - **Mitigation:** Database-level unique constraint, check on submission - **Rollback:** Refresh availability if booking fails ### Race Condition Prevention Strategy The calendar display component itself does not handle booking submission - that is Story 3.4's responsibility. However, this story must support race condition prevention by: 1. **Fresh availability check on date selection**: Always reload slots when a date is clicked (already implemented in `loadAvailableSlots()`) 2. **Database constraint** (to be added in Story 3.4 migration): ```php // In consultations table migration $table->unique(['scheduled_date', 'scheduled_time'], 'unique_consultation_slot'); ``` 3. **Optimistic UI with graceful fallback**: If a slot is selected but booking fails due to race condition, the calendar should refresh availability: ```php // In parent booking component (Story 3.4) public function selectSlot(string $date, string $time): void { // Re-verify slot is still available before proceeding $service = app(AvailabilityService::class); $availableSlots = $service->getAvailableSlots(Carbon::parse($date)); if (!in_array($time, $availableSlots)) { $this->dispatch('slot-unavailable'); $this->loadAvailableSlots(); // Refresh the calendar return; } $this->selectedDate = $date; $this->selectedTime = $time; } ``` ## Estimation **Complexity:** High **Estimated Effort:** 5-6 hours