reviewed epic 3
This commit is contained in:
@@ -42,6 +42,10 @@ So that **I can choose a convenient time for my consultation**.
|
||||
- [ ] 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
|
||||
@@ -55,6 +59,18 @@ So that **I can choose a convenient time for my consultation**.
|
||||
|
||||
## 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
|
||||
<?php
|
||||
@@ -210,6 +226,12 @@ new class extends Component {
|
||||
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;
|
||||
@@ -375,6 +397,80 @@ new class extends Component {
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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
|
||||
<div class="grid grid-cols-7 gap-1" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||
```
|
||||
|
||||
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
|
||||
@@ -389,11 +485,325 @@ new class extends Component {
|
||||
- [ ] Tests for availability logic
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### AvailabilityService Unit Tests
|
||||
|
||||
Create `tests/Unit/Services/AvailabilityServiceTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Models\{WorkingHour, BlockedTime, Consultation};
|
||||
use App\Services\AvailabilityService;
|
||||
use Carbon\Carbon;
|
||||
|
||||
beforeEach(function () {
|
||||
// Setup default working hours (Monday-Friday, 9am-5pm)
|
||||
foreach ([1, 2, 3, 4, 5] as $day) {
|
||||
WorkingHour::factory()->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
|
||||
<?php
|
||||
|
||||
use App\Models\{WorkingHour, BlockedTime, Consultation, User};
|
||||
use Livewire\Volt\Volt;
|
||||
use Carbon\Carbon;
|
||||
|
||||
beforeEach(function () {
|
||||
// Setup working hours for weekdays
|
||||
foreach ([1, 2, 3, 4, 5] as $day) {
|
||||
WorkingHour::factory()->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
|
||||
<?php
|
||||
|
||||
use App\Models\{WorkingHour, User};
|
||||
|
||||
it('allows selecting a time slot with mouse click', function () {
|
||||
// Setup working hours
|
||||
WorkingHour::factory()->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)
|
||||
- **Story 3.2:** Blocked times (removes availability)
|
||||
- **Story 3.4:** Booking submission (consumes selected slot)
|
||||
- **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
|
||||
|
||||
@@ -401,6 +811,38 @@ new class extends Component {
|
||||
- **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
|
||||
|
||||
Reference in New Issue
Block a user