libra/docs/stories/story-3.3-availability-cale...

12 KiB

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

namespace App\Services;

use App\Models\{WorkingHour, BlockedTime, Consultation};
use Carbon\Carbon;

class AvailabilityService
{
    public function getMonthAvailability(int $year, int $month): array
    {
        $startOfMonth = Carbon::create($year, $month, 1)->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

use App\Services\AvailabilityService;
use Livewire\Volt\Component;
use Carbon\Carbon;

new class extends Component {
    public int $year;
    public int $month;
    public ?string $selectedDate = null;
    public array $monthAvailability = [];
    public array $availableSlots = [];

    public function mount(): void
    {
        $this->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

<div>
    <!-- Calendar Header -->
    <div class="flex items-center justify-between mb-4">
        <flux:button size="sm" wire:click="previousMonth">
            <flux:icon name="chevron-{{ app()->getLocale() === 'ar' ? 'right' : 'left' }}" />
        </flux:button>

        <flux:heading size="lg">{{ $monthName }}</flux:heading>

        <flux:button size="sm" wire:click="nextMonth">
            <flux:icon name="chevron-{{ app()->getLocale() === 'ar' ? 'left' : 'right' }}" />
        </flux:button>
    </div>

    <!-- Day Headers -->
    <div class="grid grid-cols-7 gap-1 mb-2">
        @foreach(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as $day)
            <div class="text-center text-sm font-semibold text-charcoal">
                {{ __("calendar.{$day}") }}
            </div>
        @endforeach
    </div>

    <!-- Calendar Grid -->
    <div class="grid grid-cols-7 gap-1">
        @foreach($calendarDays as $dayData)
            @if($dayData === null)
                <div class="h-12"></div>
            @else
                <button
                    wire:click="selectDate('{{ $dayData['date'] }}')"
                    @class([
                        'h-12 rounded-lg text-center transition-colors',
                        'bg-success/20 text-success hover:bg-success/30' => $dayData['status'] === 'available',
                        'bg-warning/20 text-warning hover:bg-warning/30' => $dayData['status'] === 'partial',
                        'bg-charcoal/10 text-charcoal/50 cursor-not-allowed' => in_array($dayData['status'], ['past', 'closed', 'blocked', 'full']),
                        'ring-2 ring-gold' => $selectedDate === $dayData['date'],
                    ])
                    @disabled(in_array($dayData['status'], ['past', 'closed', 'blocked', 'full']))
                >
                    {{ $dayData['day'] }}
                </button>
            @endif
        @endforeach
    </div>

    <!-- Time Slots -->
    @if($selectedDate)
        <div class="mt-6">
            <flux:heading size="sm" class="mb-3">
                {{ __('booking.available_times') }} -
                {{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('d M Y') }}
            </flux:heading>

            @if(count($availableSlots) > 0)
                <div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
                    @foreach($availableSlots as $slot)
                        <button
                            wire:click="$parent.selectSlot('{{ $selectedDate }}', '{{ $slot }}')"
                            class="p-3 rounded-lg border border-gold text-gold hover:bg-gold hover:text-navy transition-colors"
                        >
                            {{ \Carbon\Carbon::parse($slot)->format('g:i A') }}
                        </button>
                    @endforeach
                </div>
            @else
                <p class="text-charcoal/70">{{ __('booking.no_slots_available') }}</p>
            @endif
        </div>
    @endif

    <!-- Legend -->
    <div class="flex gap-4 mt-6 text-sm">
        <div class="flex items-center gap-2">
            <div class="w-4 h-4 rounded bg-success/20"></div>
            <span>{{ __('booking.available') }}</span>
        </div>
        <div class="flex items-center gap-2">
            <div class="w-4 h-4 rounded bg-warning/20"></div>
            <span>{{ __('booking.partial') }}</span>
        </div>
        <div class="flex items-center gap-2">
            <div class="w-4 h-4 rounded bg-charcoal/10"></div>
            <span>{{ __('booking.unavailable') }}</span>
        </div>
    </div>
</div>

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

Dependencies

  • Story 3.1: Working hours (defines available time)
  • Story 3.2: Blocked times (removes availability)
  • Story 3.4: Booking submission (consumes selected slot)

Risk Assessment

  • Primary Risk: Race condition on slot selection
  • Mitigation: Database-level unique constraint, check on submission
  • Rollback: Refresh availability if booking fails

Estimation

Complexity: High Estimated Effort: 5-6 hours