Files
libra/docs/stories/story-3.2-time-slot-blocking.md
T
2025-12-26 18:43:26 +02:00

20 KiB

Story 3.2: Time Slot Blocking

Epic Reference

Epic 3: Booking & Consultation System

User Story

As an admin, I want to block specific dates or time ranges for personal events or holidays, So that clients cannot book during my unavailable times.

Story Context

Existing System Integration

  • Integrates with: blocked_times table, availability calendar
  • Technology: Livewire Volt, Flux UI
  • Follows pattern: CRUD pattern with calendar integration
  • Touch points: Availability calculation service

Acceptance Criteria

Block Time Management

  • Block entire days (all-day events)
  • Block specific time ranges within a day
  • Add reason/note for blocked time
  • View list of all blocked times (upcoming and past)
  • Edit blocked times
  • Delete blocked times

Creating Blocked Time

  • Select date (date picker)
  • Choose: All day OR specific time range
  • If time range: start time and end time
  • Optional reason/note field
  • Confirmation on save

Display & Integration

  • Blocked times show as unavailable in calendar (Story 3.3 dependency)
  • Visual distinction from "already booked" slots (Story 3.3 dependency)
  • Future blocked times don't affect existing approved bookings
  • Warning if blocking time with pending bookings

List View

  • Show all blocked times
  • Sort by date (upcoming first)
  • Filter: past/upcoming/all
  • Quick actions: edit, delete
  • Show reason if provided

Quality Requirements

  • Bilingual support
  • Audit log for create/edit/delete
  • Validation: end time after start time
  • Tests for blocking logic

Technical Notes

Database Schema

// blocked_times table
Schema::create('blocked_times', function (Blueprint $table) {
    $table->id();
    $table->date('block_date');
    $table->time('start_time')->nullable(); // null = all day
    $table->time('end_time')->nullable();   // null = all day
    $table->string('reason')->nullable();
    $table->timestamps();
});

Model

<?php

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;

class BlockedTime extends Model
{
    protected $fillable = [
        'block_date',
        'start_time',
        'end_time',
        'reason',
    ];

    protected $casts = [
        'block_date' => 'date',
    ];

    public function isAllDay(): bool
    {
        return is_null($this->start_time) && is_null($this->end_time);
    }

    public function scopeUpcoming($query)
    {
        return $query->where('block_date', '>=', today());
    }

    public function scopePast($query)
    {
        return $query->where('block_date', '<', today());
    }

    public function scopeForDate($query, $date)
    {
        return $query->where('block_date', $date);
    }

    public function blocksSlot(string $time): bool
    {
        if ($this->isAllDay()) {
            return true;
        }

        $slotTime = Carbon::parse($time);
        $start = Carbon::parse($this->start_time);
        $end = Carbon::parse($this->end_time);

        return $slotTime->between($start, $end) ||
               $slotTime->eq($start);
    }
}

Volt Component for Create/Edit

<?php

use App\Models\AdminLog;
use App\Models\BlockedTime;
use Livewire\Volt\Component;

new class extends Component {
    public ?BlockedTime $blockedTime = null;

    public string $block_date = '';
    public bool $is_all_day = true;
    public string $start_time = '09:00';
    public string $end_time = '17:00';
    public string $reason = '';

    public function mount(?BlockedTime $blockedTime = null): void
    {
        if ($blockedTime?->exists) {
            $this->blockedTime = $blockedTime;
            $this->block_date = $blockedTime->block_date->format('Y-m-d');
            $this->is_all_day = $blockedTime->isAllDay();
            $this->start_time = $blockedTime->start_time ?? '09:00';
            $this->end_time = $blockedTime->end_time ?? '17:00';
            $this->reason = $blockedTime->reason ?? '';
        } else {
            $this->block_date = today()->format('Y-m-d');
        }
    }

    public function save(): void
    {
        $validated = $this->validate([
            'block_date' => ['required', 'date', 'after_or_equal:today'],
            'is_all_day' => ['boolean'],
            'start_time' => ['required_if:is_all_day,false'],
            'end_time' => ['required_if:is_all_day,false', 'after:start_time'],
            'reason' => ['nullable', 'string', 'max:255'],
        ]);

        $data = [
            'block_date' => $this->block_date,
            'start_time' => $this->is_all_day ? null : $this->start_time,
            'end_time' => $this->is_all_day ? null : $this->end_time,
            'reason' => $this->reason ?: null,
        ];

        if ($this->blockedTime) {
            $this->blockedTime->update($data);
            $action = 'update';
        } else {
            $this->blockedTime = BlockedTime::create($data);
            $action = 'create';
        }

        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => $action,
            'target_type' => 'blocked_time',
            'target_id' => $this->blockedTime->id,
            'new_values' => $data,
            'ip_address' => request()->ip(),
        ]);

        session()->flash('success', __('messages.blocked_time_saved'));
        $this->redirect(route('admin.blocked-times.index'));
    }

    public function delete(): void
    {
        $this->blockedTime->delete();

        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => 'delete',
            'target_type' => 'blocked_time',
            'target_id' => $this->blockedTime->id,
            'ip_address' => request()->ip(),
        ]);

        session()->flash('success', __('messages.blocked_time_deleted'));
        $this->redirect(route('admin.blocked-times.index'));
    }
};

Pending Booking Warning Check

// Add to Volt component - check for pending bookings before save
public function checkPendingBookings(): array
{
    $date = Carbon::parse($this->block_date);

    return Consultation::where('scheduled_date', $date->toDateString())
        ->where('status', 'pending')
        ->when(!$this->is_all_day, function ($query) {
            $query->whereBetween('scheduled_time', [$this->start_time, $this->end_time]);
        })
        ->with('user:id,full_name,company_name')
        ->get()
        ->toArray();
}

Integration with Availability Service

// In AvailabilityService
public function getBlockedSlots(Carbon $date): array
{
    $blockedTimes = BlockedTime::forDate($date)->get();
    $blockedSlots = [];

    foreach ($blockedTimes as $blocked) {
        if ($blocked->isAllDay()) {
            // Return all possible slots as blocked
            $workingHour = WorkingHour::where('day_of_week', $date->dayOfWeek)->first();
            return $workingHour ? $workingHour->getSlots(60) : [];
        }

        // Get slots that fall within blocked 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);
}

public function isDateFullyBlocked(Carbon $date): bool
{
    return BlockedTime::forDate($date)
        ->where(function ($query) {
            $query->whereNull('start_time')
                  ->whereNull('end_time');
        })
        ->exists();
}

List View Component

<div>
    <div class="flex justify-between items-center mb-4">
        <flux:heading>{{ __('admin.blocked_times') }}</flux:heading>
        <flux:button href="{{ route('admin.blocked-times.create') }}">
            {{ __('admin.add_blocked_time') }}
        </flux:button>
    </div>

    <div class="space-y-2">
        @forelse($blockedTimes as $blocked)
            <div class="flex items-center justify-between p-4 bg-cream rounded-lg">
                <div>
                    <div class="font-semibold">
                        {{ $blocked->block_date->format('d/m/Y') }}
                    </div>
                    <div class="text-sm text-charcoal">
                        @if($blocked->isAllDay())
                            {{ __('admin.all_day') }}
                        @else
                            {{ $blocked->start_time }} - {{ $blocked->end_time }}
                        @endif
                    </div>
                    @if($blocked->reason)
                        <div class="text-sm text-charcoal/70">
                            {{ $blocked->reason }}
                        </div>
                    @endif
                </div>
                <div class="flex gap-2">
                    <flux:button size="sm" href="{{ route('admin.blocked-times.edit', $blocked) }}">
                        {{ __('common.edit') }}
                    </flux:button>
                    <flux:button size="sm" variant="danger" wire:click="delete({{ $blocked->id }})">
                        {{ __('common.delete') }}
                    </flux:button>
                </div>
            </div>
        @empty
            <p class="text-charcoal/70">{{ __('admin.no_blocked_times') }}</p>
        @endforelse
    </div>
</div>

Assumptions

  • AdminLog model exists from Epic 1 with fields: admin_id, action_type, target_type, target_id, new_values, ip_address
  • Route naming convention: admin.blocked-times.{index|create|edit}
  • Admin middleware protecting all blocked-times routes
  • Flux UI modal component available for delete confirmation

Required Translation Keys

// lang/en/*.php and lang/ar/*.php
'messages.blocked_time_saved' => 'Blocked time saved successfully',
'messages.blocked_time_deleted' => 'Blocked time deleted successfully',
'admin.blocked_times' => 'Blocked Times',
'admin.add_blocked_time' => 'Add Blocked Time',
'admin.all_day' => 'All Day',
'admin.closed' => 'Closed',
'admin.no_blocked_times' => 'No blocked times found',
'common.edit' => 'Edit',
'common.delete' => 'Delete',
'common.to' => 'to',

Test Scenarios

Feature Tests (tests/Feature/Admin/BlockedTimesTest.php)

CRUD Operations:

  • Admin can create an all-day block
  • Admin can create a time-range block (e.g., 09:00-12:00)
  • Admin can add optional reason to blocked time
  • Admin can edit an existing blocked time
  • Admin can delete a blocked time
  • Non-admin users cannot access blocked time routes

Validation:

  • Cannot create block with end_time before start_time
  • Cannot create block for past dates (new blocks only)
  • Can edit existing blocks for past dates (data integrity)
  • Reason field respects 255 character max length

List View:

  • List displays all blocked times sorted by date (upcoming first)
  • Filter by "upcoming" shows only future blocks
  • Filter by "past" shows only past blocks
  • Filter by "all" shows all blocks

Integration:

  • blocksSlot() returns true for times within blocked range
  • blocksSlot() returns true for all times when all-day block
  • blocksSlot() returns false for times outside blocked range
  • isDateFullyBlocked() correctly identifies all-day blocks (AvailabilityService - Story 3.3)
  • getBlockedSlots() returns correct slots for partial day blocks (AvailabilityService - Story 3.3)

Edge Cases:

  • Multiple blocks on same date handled correctly
  • Block at end of working hours (edge of range)
  • Warning displayed when blocking date with pending consultations

Unit Tests (tests/Unit/Models/BlockedTimeTest.php)

  • isAllDay() returns true when start_time and end_time are null
  • isAllDay() returns false when times are set
  • scopeUpcoming() filters correctly
  • scopePast() filters correctly
  • scopeForDate() filters by exact date

Definition of Done

  • Can create all-day blocks
  • Can create time-range blocks
  • Can add reason to blocked time
  • List view shows all blocked times
  • Can edit blocked times
  • Can delete blocked times
  • Blocked times show as unavailable in calendar (Story 3.3 dependency)
  • Existing bookings not affected
  • Audit logging complete
  • Bilingual support
  • Tests pass
  • Code formatted with Pint

Dependencies

  • Story 3.1: Working hours configuration (docs/stories/story-3.1-working-hours-configuration.md)
    • Provides: WorkingHour model, AvailabilityService base
  • Story 3.3: Availability calendar (docs/stories/story-3.3-availability-calendar-display.md)
    • Consumes: blocked times data for calendar display
  • Epic 1: Core Foundation
    • Provides: AdminLog model for audit logging, admin authentication

Risk Assessment

  • Primary Risk: Blocking times with pending bookings
  • Mitigation: Warning message, don't auto-cancel existing bookings
  • Rollback: Delete blocked time to restore availability

Estimation

Complexity: Medium Estimated Effort: 3-4 hours


Dev Agent Record

Status

Ready for Review

Agent Model Used

Claude Opus 4.5 (claude-opus-4-5-20251101)

File List

New Files:

  • resources/views/livewire/admin/settings/blocked-times.blade.php - Volt component for blocked times CRUD
  • tests/Unit/Models/BlockedTimeTest.php - Unit tests for BlockedTime model
  • tests/Feature/Admin/BlockedTimesTest.php - Feature tests for blocked times CRUD
  • lang/en/common.php - Common translations (save, cancel, edit, delete, optional)
  • lang/ar/common.php - Arabic common translations

Modified Files:

  • app/Models/BlockedTime.php - Added scopes (upcoming, past, forDate) and blocksSlot method
  • database/factories/BlockedTimeFactory.php - Enhanced with allDay, timeRange, upcoming, past, today, withReason states
  • routes/web.php - Added blocked-times route
  • lang/en/admin.php - Added blocked times translations
  • lang/ar/admin.php - Added Arabic blocked times translations
  • lang/en/messages.php - Added blocked_time_saved, blocked_time_deleted messages
  • lang/ar/messages.php - Added Arabic blocked time messages
  • lang/en/validation.php - Added block_date_future validation message
  • lang/ar/validation.php - Added Arabic block_date_future validation message
  • lang/en/clients.php - Added 'unknown' translation key
  • lang/ar/clients.php - Added Arabic 'unknown' translation key

Change Log

  • Implemented full CRUD for blocked times with modal-based create/edit
  • Added all-day and time-range blocking support
  • Added filter for upcoming/past/all blocked times
  • Added pending booking warning when blocking dates with existing consultations
  • Added audit logging for create/update/delete operations
  • Added bilingual support (English/Arabic)
  • Created comprehensive unit and feature tests (46 tests, 106 assertions)

Completion Notes

  • Calendar integration (showing blocked times as unavailable) is deferred to Story 3.3
  • AvailabilityService integration (getBlockedSlots, isDateFullyBlocked) is deferred to Story 3.3
  • All 303 tests in the full test suite pass
  • Code formatted with Pint

QA Results

Review Date: 2025-12-26

Reviewed By: Quinn (Test Architect)

Code Quality Assessment

Overall: Excellent - The implementation demonstrates high-quality code with clean architecture, proper separation of concerns, and comprehensive test coverage. The Volt component follows established patterns in the codebase, and the model implementation is clean with well-designed scopes.

Strengths:

  • Clean class-based Volt component with proper state management
  • Well-structured modal flow for create/edit operations
  • Comprehensive pending booking warning system with reactive updates
  • Proper audit logging with old/new values capture
  • Factory states are comprehensive and follow Laravel conventions
  • blocksSlot() method correctly handles boundary conditions (inclusive start, exclusive end)

Code Quality Highlights:

  • Model scopes (upcoming, past, forDate) are properly typed with Builder return types
  • Carbon usage is appropriate for date/time manipulation
  • Flux UI components are used consistently with the project patterns
  • Proper wire:key usage in loops for optimal Livewire rendering

Refactoring Performed

None required - code quality meets project standards.

Compliance Check

  • Coding Standards: ✓ Code follows Laravel/Pint conventions
  • Project Structure: ✓ Files in correct locations following project patterns
  • Testing Strategy: ✓ Comprehensive unit and feature tests
  • All ACs Met: ✓ All acceptance criteria marked as complete (where applicable to this story)

Improvements Checklist

All items are addressed or appropriately deferred:

  • CRUD operations fully implemented and tested
  • Modal-based UI for create/edit with proper state management
  • Pending booking warning with reactive updates
  • Audit logging for all operations
  • Bilingual support (EN/AR)
  • Filter functionality (upcoming/past/all)
  • Validation rules properly applied
  • Delete confirmation modal
  • Calendar display integration (correctly deferred to Story 3.3)
  • AvailabilityService integration (correctly deferred to Story 3.3)

Security Review

Status: PASS

  • Route properly protected by admin middleware
  • Authorization tests verify non-admin access is forbidden
  • No direct user input used in queries without validation
  • Audit logging captures IP addresses for traceability

Performance Considerations

Status: PASS

  • Efficient queries using scopes
  • Eager loading used for user relation in pending bookings check
  • No N+1 query issues detected
  • List view uses simple pagination pattern (no performance concerns at expected scale)

Files Modified During Review

None - no files were modified during review.

Gate Status

Gate: PASS → docs/qa/gates/3.2-time-slot-blocking.yml

Requirements Traceability

AC# Acceptance Criteria Test Coverage Status
1 Block entire days (all-day events) admin can create an all-day block ✓
2 Block specific time ranges admin can create a time-range block ✓
3 Add reason/note for blocked time admin can create block without reason, list shows reason if provided ✓
4 View list of all blocked times list displays all blocked times sorted by date ✓
5 Edit blocked times admin can edit an existing blocked time, admin can change block from all-day to time-range ✓
6 Delete blocked times admin can delete a blocked time ✓
7 Select date (date picker) Component uses native date input ✓
8 Choose all day or time range is_all_day switch with conditional time fields ✓
9 Time range selection start_time, end_time inputs when not all-day ✓
10 Optional reason field Nullable validation, tested ✓
11 Future blocks don't affect approved bookings Only checks pending status ✓
12 Warning for pending bookings warning displayed when blocking date with pending consultations ✓
13 Sort by date (upcoming first) filter by upcoming shows only future blocks ✓
14 Filter: past/upcoming/all 3 filter tests ✓
15 Quick actions: edit, delete Buttons in list view ✓
16 Bilingual support EN/AR translation files ✓
17 Audit logging 3 audit log tests ✓
18 Validation: end time after start cannot create block with end time before start time ✓
19 Non-admin access forbidden non-admin cannot access blocked times page ✓

✓ Ready for Done - All acceptance criteria are met, tests pass (46 tests, 106 assertions), code quality is high, and calendar integration items are correctly deferred to Story 3.3.