generated sotries
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
# 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
|
||||
<?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
|
||||
<?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
|
||||
```blade
|
||||
<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
|
||||
Reference in New Issue
Block a user