# 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