├── .gitignore ├── LICENSE ├── README.md ├── art └── logo.png ├── composer.json ├── config └── zap.php ├── database └── migrations │ ├── 2024_01_01_000001_create_schedules_table.php │ ├── 2024_01_01_000002_create_schedule_periods_table.php │ └── 2024_01_01_000003_add_schedule_type_to_schedules_table.php ├── docs └── schedule-types.md ├── phpstan.neon ├── phpunit.xml ├── src ├── Builders │ └── ScheduleBuilder.php ├── Enums │ └── ScheduleTypes.php ├── Events │ └── ScheduleCreated.php ├── Exceptions │ ├── InvalidScheduleException.php │ ├── ScheduleConflictException.php │ └── ZapException.php ├── Facades │ └── Zap.php ├── Models │ ├── Concerns │ │ └── HasSchedules.php │ ├── Schedule.php │ └── SchedulePeriod.php ├── Services │ ├── ConflictDetectionService.php │ ├── ScheduleService.php │ └── ValidationService.php └── ZapServiceProvider.php └── tests ├── Feature ├── ConflictDetectionTest.php ├── ImprovedValidationErrorsTest.php ├── OriginalIssuesFixedTest.php ├── OverlapRulesEdgeCasesTest.php ├── RecurringScheduleAvailabilityTest.php ├── ReproduceBugsTest.php ├── RuleControlTest.php ├── ScheduleManagementTest.php ├── ScheduleTypesTest.php ├── SlotsEdgeCasesTest.php └── SlotsFeatureComprehensiveTest.php ├── Pest.php ├── TestCase.php ├── Unit └── ScheduleBuilderTest.php └── phpstan └── Users.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | node_modules/ 3 | .env 4 | .env.local 5 | .env.*.local 6 | .phpunit.cache 7 | build/ 8 | composer.lock 9 | *.log 10 | *.cache 11 | .DS_Store 12 | Thumbs.db 13 | .cursor/ 14 | .idea/ 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Laravel Jutsu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /art/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ludoguenet/laravel-zap/14eb845c3a6e767135464ee7b08de20fdcf9a7ff/art/logo.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laraveljutsu/zap", 3 | "description": "A flexible, performant, and developer-friendly schedule management system for Laravel", 4 | "type": "library", 5 | "keywords": ["laravel", "schedule", "management", "calendar", "booking"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Laravel Jutsu", 10 | "email": "ludo@epekta.com", 11 | "homepage": "https://laraveljutsu.net" 12 | } 13 | ], 14 | "require": { 15 | "php": "^8.2" 16 | }, 17 | "require-dev": { 18 | "laravel/pint": "^1.20", 19 | "larastan/larastan": "^3.0", 20 | "pestphp/pest": "^3.7", 21 | "orchestra/testbench": "^9.10" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Zap\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Zap\\Tests\\": "tests/" 31 | } 32 | }, 33 | "extra": { 34 | "laravel": { 35 | "providers": [ 36 | "Zap\\ZapServiceProvider" 37 | ], 38 | "aliases": { 39 | "Zap": "Zap\\Facades\\Zap" 40 | } 41 | } 42 | }, 43 | "minimum-stability": "dev", 44 | "prefer-stable": true, 45 | "config": { 46 | "sort-packages": true, 47 | "allow-plugins": { 48 | "pestphp/pest-plugin": true 49 | } 50 | }, 51 | "scripts": { 52 | "pint": [ 53 | "@php ./vendor/bin/pint" 54 | ], 55 | "stan": [ 56 | "@php ./vendor/bin/phpstan analyse" 57 | ], 58 | "pest": [ 59 | "@php ./vendor/bin/pest" 60 | ], 61 | "qa": [ 62 | "@php ./vendor/bin/pint --parallel", 63 | "@php ./vendor/bin/phpstan analyse", 64 | "@php ./vendor/bin/pest" 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /config/zap.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | return [ 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | Default Schedule Rules 7 | |-------------------------------------------------------------------------- 8 | | 9 | | These are the default validation rules that will be applied to all 10 | | schedules unless overridden during creation. 11 | | 12 | */ 13 | 'default_rules' => [ 14 | 'no_overlap' => [ 15 | 'enabled' => true, 16 | 'applies_to' => [ 17 | // Which schedule types get this rule automatically 18 | \Zap\Enums\ScheduleTypes::APPOINTMENT, 19 | \Zap\Enums\ScheduleTypes::BLOCKED, 20 | ], 21 | ], 22 | 'working_hours' => [ 23 | 'enabled' => false, 24 | 'start' => '09:00', 25 | 'end' => '17:00', 26 | 'timezone' => null, // Uses app timezone if null 27 | ], 28 | 'max_duration' => [ 29 | 'enabled' => false, 30 | 'minutes' => 480, // 8 hours 31 | ], 32 | 'no_weekends' => [ 33 | 'enabled' => false, 34 | 'saturday' => true, 35 | 'sunday' => true, 36 | ], 37 | ], 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Conflict Detection 42 | |-------------------------------------------------------------------------- 43 | | 44 | | Configure how schedule conflicts are detected and handled. 45 | | 46 | */ 47 | 'conflict_detection' => [ 48 | 'enabled' => true, 49 | 'buffer_minutes' => 0, // Buffer time between schedules 50 | 'auto_resolve' => false, // Automatically resolve conflicts 51 | 'strict_mode' => true, // Throw exceptions on conflicts 52 | ], 53 | 54 | /* 55 | |-------------------------------------------------------------------------- 56 | | Recurring Schedules 57 | |-------------------------------------------------------------------------- 58 | | 59 | | Settings for processing recurring schedules and cleanup. 60 | | 61 | */ 62 | 'recurring' => [ 63 | 'process_days_ahead' => 30, // Generate instances this many days ahead 64 | 'cleanup_expired_after_days' => 90, // Clean up expired schedules after X days 65 | 'max_instances' => 1000, // Maximum instances to generate at once 66 | 'supported_frequencies' => ['daily', 'weekly', 'monthly', 'yearly'], 67 | ], 68 | 69 | /* 70 | |-------------------------------------------------------------------------- 71 | | Cache Settings 72 | |-------------------------------------------------------------------------- 73 | | 74 | | Configure caching for schedule queries and conflict detection. 75 | | 76 | */ 77 | 'cache' => [ 78 | 'enabled' => true, 79 | 'ttl' => 3600, // 1 hour in seconds 80 | 'prefix' => 'zap_schedule_', 81 | 'tags' => ['zap', 'schedules'], 82 | 'store' => null, // Uses default cache store if null 83 | ], 84 | 85 | /* 86 | |-------------------------------------------------------------------------- 87 | | Time Slots Configuration 88 | |-------------------------------------------------------------------------- 89 | | 90 | | Default settings for time slot generation and availability checking. 91 | | 92 | */ 93 | 'time_slots' => [ 94 | 'default_duration' => 60, // minutes 95 | 'min_duration' => 15, // minutes 96 | 'max_duration' => 480, // minutes (8 hours) 97 | 'business_hours' => [ 98 | 'start' => '09:00', 99 | 'end' => '17:00', 100 | ], 101 | 'slot_intervals' => [15, 30, 60, 120], // Available slot durations 102 | ], 103 | 104 | /* 105 | |-------------------------------------------------------------------------- 106 | | Validation Rules 107 | |-------------------------------------------------------------------------- 108 | | 109 | | Configure custom validation rules and their settings. 110 | | 111 | */ 112 | 'validation' => [ 113 | 'require_future_dates' => true, // Schedules must be in the future 114 | 'max_date_range' => 365, // Maximum days between start and end date 115 | 'min_period_duration' => 15, // Minimum period duration in minutes 116 | 'max_period_duration' => 480, // Maximum period duration in minutes 117 | 'max_periods_per_schedule' => 50, // Maximum periods per schedule 118 | 'allow_overlapping_periods' => false, // Allow periods to overlap within same schedule 119 | ], 120 | 121 | /* 122 | |-------------------------------------------------------------------------- 123 | | Event Listeners 124 | |-------------------------------------------------------------------------- 125 | | 126 | | Configure which events should be fired and handled. 127 | | 128 | */ 129 | 'events' => [ 130 | 'schedule_created' => true, 131 | ], 132 | ]; 133 | -------------------------------------------------------------------------------- /database/migrations/2024_01_01_000001_create_schedules_table.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Illuminate\Database\Migrations\Migration; 4 | use Illuminate\Database\Schema\Blueprint; 5 | use Illuminate\Support\Facades\Schema; 6 | 7 | return new class extends Migration 8 | { 9 | /** 10 | * Run the migrations. 11 | */ 12 | public function up(): void 13 | { 14 | Schema::create('schedules', function (Blueprint $table) { 15 | $table->id(); 16 | $table->morphs('schedulable'); // User, Resource, etc. 17 | $table->string('name')->nullable(); 18 | $table->text('description')->nullable(); 19 | $table->date('start_date'); 20 | $table->date('end_date')->nullable(); 21 | $table->boolean('is_recurring')->default(false); 22 | $table->string('frequency')->nullable(); // daily, weekly, monthly 23 | $table->json('frequency_config')->nullable(); 24 | $table->json('metadata')->nullable(); 25 | $table->boolean('is_active')->default(true); 26 | $table->timestamps(); 27 | 28 | // Indexes for performance 29 | $table->index(['schedulable_type', 'schedulable_id'], 'schedules_schedulable_index'); 30 | $table->index(['start_date', 'end_date'], 'schedules_date_range_index'); 31 | $table->index('is_active', 'schedules_is_active_index'); 32 | $table->index('is_recurring', 'schedules_is_recurring_index'); 33 | $table->index('frequency', 'schedules_frequency_index'); 34 | }); 35 | } 36 | 37 | /** 38 | * Reverse the migrations. 39 | */ 40 | public function down(): void 41 | { 42 | Schema::dropIfExists('schedules'); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /database/migrations/2024_01_01_000002_create_schedule_periods_table.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Illuminate\Database\Migrations\Migration; 4 | use Illuminate\Database\Schema\Blueprint; 5 | use Illuminate\Support\Facades\Schema; 6 | 7 | return new class extends Migration 8 | { 9 | /** 10 | * Run the migrations. 11 | */ 12 | public function up(): void 13 | { 14 | Schema::create('schedule_periods', function (Blueprint $table) { 15 | $table->id(); 16 | $table->foreignId('schedule_id')->constrained()->cascadeOnDelete(); 17 | $table->date('date'); 18 | $table->time('start_time'); 19 | $table->time('end_time'); 20 | $table->boolean('is_available')->default(true); 21 | $table->json('metadata')->nullable(); 22 | $table->timestamps(); 23 | 24 | // Indexes for performance 25 | $table->index(['schedule_id', 'date'], 'schedule_periods_schedule_date_index'); 26 | $table->index(['date', 'start_time', 'end_time'], 'schedule_periods_time_range_index'); 27 | $table->index('is_available', 'schedule_periods_is_available_index'); 28 | }); 29 | } 30 | 31 | /** 32 | * Reverse the migrations. 33 | */ 34 | public function down(): void 35 | { 36 | Schema::dropIfExists('schedule_periods'); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /database/migrations/2024_01_01_000003_add_schedule_type_to_schedules_table.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Illuminate\Database\Migrations\Migration; 4 | use Illuminate\Database\Schema\Blueprint; 5 | use Illuminate\Support\Facades\Schema; 6 | use Zap\Enums\ScheduleTypes; 7 | 8 | return new class extends Migration 9 | { 10 | /** 11 | * Run the migrations. 12 | */ 13 | public function up(): void 14 | { 15 | Schema::table('schedules', function (Blueprint $table) { 16 | $table->enum('schedule_type', ScheduleTypes::values()) 17 | ->default(ScheduleTypes::CUSTOM) 18 | ->after('description'); 19 | 20 | // Add indexes for performance 21 | $table->index('schedule_type', 'schedules_type_index'); 22 | $table->index(['schedulable_type', 'schedulable_id', 'schedule_type'], 'schedules_schedulable_type_index'); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | */ 29 | public function down(): void 30 | { 31 | Schema::table('schedules', function (Blueprint $table) { 32 | $table->dropIndex('schedules_type_index'); 33 | $table->dropIndex('schedules_schedulable_type_index'); 34 | $table->dropColumn('schedule_type'); 35 | }); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /docs/schedule-types.md: -------------------------------------------------------------------------------- 1 | # Schedule Types in Laravel Zap 2 | 3 | Laravel Zap now supports different types of schedules to handle complex scheduling scenarios like hospital management systems, appointment booking, and resource allocation. 4 | 5 | ## Overview 6 | 7 | The new `schedule_type` field allows you to distinguish between different types of schedules and control how they interact with each other: 8 | 9 | - **Availability**: Working hours or open time slots that allow overlaps 10 | - **Appointment**: Actual bookings that prevent overlaps 11 | - **Blocked**: Unavailable time periods that prevent overlaps 12 | - **Custom**: Default type for backward compatibility 13 | 14 | ## Schedule Types 15 | 16 | ### 1. Availability Schedules 17 | 18 | Availability schedules represent working hours or open time slots where appointments can be booked. These schedules **allow overlaps** and are typically used to define when someone or something is available. 19 | 20 | ```php 21 | // Define working hours 22 | $workingHours = Zap::for($doctor) 23 | ->named('Office Hours') 24 | ->description('Available for patient appointments') 25 | ->availability() 26 | ->from('2025-01-01') 27 | ->to('2025-12-31') 28 | ->addPeriod('09:00', '12:00') // Morning session 29 | ->addPeriod('14:00', '17:00') // Afternoon session 30 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 31 | ->save(); 32 | ``` 33 | 34 | ### 2. Appointment Schedules 35 | 36 | Appointment schedules represent actual bookings that **prevent overlaps**. These are the concrete appointments that get scheduled within availability windows. 37 | 38 | ```php 39 | // Create a patient appointment 40 | $appointment = Zap::for($doctor) 41 | ->named('Patient A - Checkup') 42 | ->description('Annual checkup appointment') 43 | ->appointment() 44 | ->from('2025-01-01') 45 | ->addPeriod('10:00', '11:00') 46 | ->withMetadata([ 47 | 'patient_id' => 1, 48 | 'appointment_type' => 'checkup', 49 | 'notes' => 'Annual physical examination' 50 | ]) 51 | ->save(); 52 | ``` 53 | 54 | ### 3. Blocked Schedules 55 | 56 | Blocked schedules represent unavailable time periods that **prevent overlaps**. These are typically used for lunch breaks, holidays, or other times when no appointments should be scheduled. 57 | 58 | ```php 59 | // Define lunch break 60 | $lunchBreak = Zap::for($doctor) 61 | ->named('Lunch Break') 62 | ->description('Unavailable for appointments') 63 | ->blocked() 64 | ->from('2025-01-01') 65 | ->to('2025-12-31') 66 | ->addPeriod('12:00', '13:00') 67 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 68 | ->save(); 69 | ``` 70 | 71 | ### 4. Custom Schedules 72 | 73 | Custom schedules are the default type for backward compatibility. They don't have predefined overlap behavior and rely on the `noOverlap()` rule if specified. 74 | 75 | ```php 76 | // Custom schedule (default behavior) 77 | $custom = Zap::for($user) 78 | ->named('Custom Event') 79 | ->custom() 80 | ->from('2025-01-01') 81 | ->addPeriod('15:00', '16:00') 82 | ->save(); 83 | ``` 84 | 85 | ## Usage Examples 86 | 87 | ### Hospital Scheduling System 88 | 89 | ```php 90 | // Doctor's working hours (availability) 91 | $workingHours = Zap::for($doctor) 92 | ->named('Dr. Smith - Office Hours') 93 | ->availability() 94 | ->from('2025-01-01') 95 | ->to('2025-12-31') 96 | ->addPeriod('09:00', '12:00') 97 | ->addPeriod('14:00', '17:00') 98 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 99 | ->save(); 100 | 101 | // Lunch break (blocked) 102 | $lunchBreak = Zap::for($doctor) 103 | ->named('Lunch Break') 104 | ->blocked() 105 | ->from('2025-01-01') 106 | ->to('2025-12-31') 107 | ->addPeriod('12:00', '13:00') 108 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 109 | ->save(); 110 | 111 | // Patient appointments 112 | $appointment1 = Zap::for($doctor) 113 | ->named('Patient A - Consultation') 114 | ->appointment() 115 | ->from('2025-01-01') 116 | ->addPeriod('10:00', '11:00') 117 | ->withMetadata(['patient_id' => 1, 'type' => 'consultation']) 118 | ->save(); 119 | 120 | $appointment2 = Zap::for($doctor) 121 | ->named('Patient B - Follow-up') 122 | ->appointment() 123 | ->from('2025-01-01') 124 | ->addPeriod('15:00', '16:00') 125 | ->withMetadata(['patient_id' => 2, 'type' => 'follow-up']) 126 | ->save(); 127 | ``` 128 | 129 | ### Resource Booking System 130 | 131 | ```php 132 | // Conference room availability 133 | $roomAvailability = Zap::for($conferenceRoom) 134 | ->named('Conference Room A - Available') 135 | ->availability() 136 | ->from('2025-01-01') 137 | ->to('2025-12-31') 138 | ->addPeriod('08:00', '18:00') 139 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 140 | ->save(); 141 | 142 | // Room bookings 143 | $meeting1 = Zap::for($conferenceRoom) 144 | ->named('Team Standup') 145 | ->appointment() 146 | ->from('2025-01-01') 147 | ->addPeriod('09:00', '10:00') 148 | ->withMetadata(['organizer' => 'john@company.com', 'attendees' => 8]) 149 | ->save(); 150 | 151 | $meeting2 = Zap::for($conferenceRoom) 152 | ->named('Client Presentation') 153 | ->appointment() 154 | ->from('2025-01-01') 155 | ->addPeriod('14:00', '16:00') 156 | ->withMetadata(['organizer' => 'jane@company.com', 'attendees' => 15]) 157 | ->save(); 158 | ``` 159 | 160 | ## Querying by Schedule Type 161 | 162 | ### Using Model Scopes 163 | 164 | ```php 165 | // Get all availability schedules 166 | $availabilitySchedules = Schedule::availability()->get(); 167 | 168 | // Get all appointment schedules 169 | $appointmentSchedules = Schedule::appointments()->get(); 170 | 171 | // Get all blocked schedules 172 | $blockedSchedules = Schedule::blocked()->get(); 173 | 174 | // Get schedules of specific type 175 | $customSchedules = Schedule::ofType('custom')->get(); 176 | ``` 177 | 178 | ### Using Relationship Methods 179 | 180 | ```php 181 | // Get availability schedules for a user 182 | $userAvailability = $user->availabilitySchedules()->get(); 183 | 184 | // Get appointment schedules for a user 185 | $userAppointments = $user->appointmentSchedules()->get(); 186 | 187 | // Get blocked schedules for a user 188 | $userBlocked = $user->blockedSchedules()->get(); 189 | 190 | // Get schedules of specific type 191 | $userCustom = $user->schedulesOfType('custom')->get(); 192 | ``` 193 | 194 | ## Availability Checking 195 | 196 | The `isAvailableAt()` method now properly handles different schedule types: 197 | 198 | ```php 199 | // Check if doctor is available at specific time 200 | $isAvailable = $doctor->isAvailableAt('2025-01-01', '10:00', '11:00'); 201 | 202 | // This will return false if there's an appointment or blocked time 203 | // This will return true if the time is within availability windows 204 | ``` 205 | 206 | ## Conflict Detection 207 | 208 | Conflict detection now respects schedule types: 209 | 210 | - **Availability schedules** never cause conflicts 211 | - **Appointment schedules** conflict with other appointments and blocked schedules 212 | - **Blocked schedules** conflict with appointments and other blocked schedules 213 | - **Custom schedules** follow the `noOverlap()` rule if specified 214 | 215 | ```php 216 | // This will not cause a conflict (availability allows overlaps) 217 | $availability = Zap::for($user) 218 | ->availability() 219 | ->from('2025-01-01') 220 | ->addPeriod('09:00', '17:00') 221 | ->save(); 222 | 223 | // This will cause a conflict with the appointment below 224 | $appointment1 = Zap::for($user) 225 | ->appointment() 226 | ->from('2025-01-01') 227 | ->addPeriod('10:00', '11:00') 228 | ->save(); 229 | 230 | // This will throw ScheduleConflictException 231 | try { 232 | $appointment2 = Zap::for($user) 233 | ->appointment() 234 | ->from('2025-01-01') 235 | ->addPeriod('10:30', '11:30') // Overlaps with appointment1 236 | ->save(); 237 | } catch (ScheduleConflictException $e) { 238 | // Handle conflict 239 | } 240 | ``` 241 | 242 | ## Migration from Metadata Approach 243 | 244 | If you were previously using the metadata field to store schedule types, you can migrate to the new approach: 245 | 246 | ### Before (using metadata) 247 | ```php 248 | $schedule = Zap::for($user) 249 | ->from('2025-01-01') 250 | ->addPeriod('09:00', '17:00') 251 | ->withMetadata(['type' => 'availability']) 252 | ->save(); 253 | ``` 254 | 255 | ### After (using schedule_type) 256 | ```php 257 | $schedule = Zap::for($user) 258 | ->availability() 259 | ->from('2025-01-01') 260 | ->addPeriod('09:00', '17:00') 261 | ->save(); 262 | ``` 263 | 264 | ## Backward Compatibility 265 | 266 | The new schedule types feature maintains full backward compatibility: 267 | 268 | - Existing schedules will default to `custom` type 269 | - The `noOverlap()` method still works as before 270 | - All existing API methods continue to function 271 | 272 | ## Performance Benefits 273 | 274 | The new `schedule_type` column provides several performance benefits: 275 | 276 | 1. **Indexed queries**: The `schedule_type` column is indexed for faster filtering 277 | 2. **Reduced conflict checks**: Only relevant schedule types are checked for conflicts 278 | 3. **Better query optimization**: Database can use type-specific indexes 279 | 280 | ## Best Practices 281 | 282 | 1. **Use appropriate types**: Always use the most specific schedule type for your use case 283 | 2. **Combine with metadata**: Use the `metadata` field for additional context-specific information 284 | 3. **Query efficiently**: Use type-specific scopes when querying large datasets 285 | 4. **Plan your schema**: Consider your scheduling requirements when choosing between types 286 | 287 | ## Database Schema 288 | 289 | The new `schedule_type` column is added to the `schedules` table: 290 | 291 | ```sql 292 | ALTER TABLE schedules ADD COLUMN schedule_type ENUM('availability', 'appointment', 'blocked', 'custom') DEFAULT 'custom'; 293 | CREATE INDEX schedules_type_index ON schedules(schedule_type); 294 | CREATE INDEX schedules_schedulable_type_index ON schedules(schedulable_type, schedulable_id, schedule_type); 295 | ``` 296 | 297 | This enhancement makes Laravel Zap much more suitable for complex scheduling scenarios like hospital management systems, where the distinction between availability windows and actual appointments is crucial. 298 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | 4 | parameters: 5 | paths: 6 | - src/ 7 | - tests/phpstan/Users.php 8 | 9 | level: 5 10 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 | xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.2/phpunit.xsd" 4 | bootstrap="vendor/autoload.php" 5 | colors="true" 6 | cacheDirectory=".phpunit.cache"> 7 | <testsuites> 8 | <testsuite name="Zap"> 9 | <directory suffix="Test.php">./tests</directory> 10 | </testsuite> 11 | </testsuites> 12 | <source> 13 | <include> 14 | <directory suffix=".php">./src</directory> 15 | </include> 16 | </source> 17 | <coverage> 18 | <report> 19 | <html outputDirectory="build/coverage"/> 20 | <text outputFile="build/coverage.txt"/> 21 | <clover outputFile="build/logs/clover.xml"/> 22 | </report> 23 | </coverage> 24 | <logging> 25 | <junit outputFile="build/report.junit.xml"/> 26 | </logging> 27 | </phpunit> 28 | -------------------------------------------------------------------------------- /src/Builders/ScheduleBuilder.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Builders; 4 | 5 | use Carbon\Carbon; 6 | use Illuminate\Database\Eloquent\Model; 7 | use Zap\Enums\ScheduleTypes; 8 | use Zap\Models\Schedule; 9 | use Zap\Services\ScheduleService; 10 | 11 | class ScheduleBuilder 12 | { 13 | private ?Model $schedulable = null; 14 | 15 | private array $attributes = []; 16 | 17 | private array $periods = []; 18 | 19 | private array $rules = []; 20 | 21 | /** 22 | * Set the schedulable model (User, etc.) 23 | */ 24 | public function for(Model $schedulable): self 25 | { 26 | $this->schedulable = $schedulable; 27 | 28 | return $this; 29 | } 30 | 31 | /** 32 | * Set the schedule name. 33 | */ 34 | public function named(string $name): self 35 | { 36 | $this->attributes['name'] = $name; 37 | 38 | return $this; 39 | } 40 | 41 | /** 42 | * Set the schedule description. 43 | */ 44 | public function description(string $description): self 45 | { 46 | $this->attributes['description'] = $description; 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * Set the start date. 53 | */ 54 | public function from(Carbon|string $startDate): self 55 | { 56 | $this->attributes['start_date'] = $startDate instanceof Carbon 57 | ? $startDate->toDateString() 58 | : $startDate; 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * Set the end date. 65 | */ 66 | public function to(Carbon|string|null $endDate): self 67 | { 68 | $this->attributes['end_date'] = $endDate instanceof Carbon 69 | ? $endDate->toDateString() 70 | : $endDate; 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * Set both start and end dates. 77 | */ 78 | public function between(Carbon|string $start, Carbon|string $end): self 79 | { 80 | return $this->from($start)->to($end); 81 | } 82 | 83 | /** 84 | * Add a time period to the schedule. 85 | */ 86 | public function addPeriod(string $startTime, string $endTime, ?Carbon $date = null): self 87 | { 88 | $this->periods[] = [ 89 | 'start_time' => $startTime, 90 | 'end_time' => $endTime, 91 | 'date' => $date?->toDateString() ?? $this->attributes['start_date'] ?? now()->toDateString(), 92 | ]; 93 | 94 | return $this; 95 | } 96 | 97 | /** 98 | * Add multiple periods at once. 99 | */ 100 | public function addPeriods(array $periods): self 101 | { 102 | foreach ($periods as $period) { 103 | $this->addPeriod( 104 | $period['start_time'], 105 | $period['end_time'], 106 | isset($period['date']) ? Carbon::parse($period['date']) : null 107 | ); 108 | } 109 | 110 | return $this; 111 | } 112 | 113 | /** 114 | * Set schedule as daily recurring. 115 | */ 116 | public function daily(): self 117 | { 118 | $this->attributes['is_recurring'] = true; 119 | $this->attributes['frequency'] = 'daily'; 120 | 121 | return $this; 122 | } 123 | 124 | /** 125 | * Set schedule as weekly recurring. 126 | */ 127 | public function weekly(array $days = []): self 128 | { 129 | $this->attributes['is_recurring'] = true; 130 | $this->attributes['frequency'] = 'weekly'; 131 | $this->attributes['frequency_config'] = ['days' => $days]; 132 | 133 | return $this; 134 | } 135 | 136 | /** 137 | * Set schedule as monthly recurring. 138 | */ 139 | public function monthly(array $config = []): self 140 | { 141 | $this->attributes['is_recurring'] = true; 142 | $this->attributes['frequency'] = 'monthly'; 143 | $this->attributes['frequency_config'] = $config; 144 | 145 | return $this; 146 | } 147 | 148 | /** 149 | * Set custom recurring frequency. 150 | */ 151 | public function recurring(string $frequency, array $config = []): self 152 | { 153 | $this->attributes['is_recurring'] = true; 154 | $this->attributes['frequency'] = $frequency; 155 | $this->attributes['frequency_config'] = $config; 156 | 157 | return $this; 158 | } 159 | 160 | /** 161 | * Add a validation rule. 162 | */ 163 | public function withRule(string $ruleName, array $config = []): self 164 | { 165 | $this->rules[$ruleName] = $config; 166 | 167 | return $this; 168 | } 169 | 170 | /** 171 | * Add no overlap rule. 172 | */ 173 | public function noOverlap(): self 174 | { 175 | return $this->withRule('no_overlap'); 176 | } 177 | 178 | /** 179 | * Set schedule as availability type (allows overlaps). 180 | */ 181 | public function availability(): self 182 | { 183 | $this->attributes['schedule_type'] = ScheduleTypes::AVAILABILITY; 184 | 185 | return $this; 186 | } 187 | 188 | /** 189 | * Set schedule as appointment type (prevents overlaps). 190 | */ 191 | public function appointment(): self 192 | { 193 | $this->attributes['schedule_type'] = ScheduleTypes::APPOINTMENT; 194 | 195 | return $this; 196 | } 197 | 198 | /** 199 | * Set schedule as blocked type (prevents overlaps). 200 | */ 201 | public function blocked(): self 202 | { 203 | $this->attributes['schedule_type'] = ScheduleTypes::BLOCKED; 204 | 205 | return $this; 206 | } 207 | 208 | /** 209 | * Set schedule as custom type. 210 | */ 211 | public function custom(): self 212 | { 213 | $this->attributes['schedule_type'] = ScheduleTypes::CUSTOM; 214 | 215 | return $this; 216 | } 217 | 218 | /** 219 | * Set schedule type explicitly. 220 | */ 221 | public function type(string $type): self 222 | { 223 | try { 224 | $scheduleType = ScheduleTypes::from($type); 225 | } catch (\ValueError) { 226 | throw new \InvalidArgumentException("Invalid schedule type: {$type}. Valid types are: ".implode(', ', ScheduleTypes::values())); 227 | } 228 | 229 | $this->attributes['schedule_type'] = $scheduleType; 230 | 231 | return $this; 232 | } 233 | 234 | /** 235 | * Add working hours only rule. 236 | */ 237 | public function workingHoursOnly(string $start = '09:00', string $end = '17:00'): self 238 | { 239 | return $this->withRule('working_hours', compact('start', 'end')); 240 | } 241 | 242 | /** 243 | * Add maximum duration rule. 244 | */ 245 | public function maxDuration(int $minutes): self 246 | { 247 | return $this->withRule('max_duration', ['minutes' => $minutes]); 248 | } 249 | 250 | /** 251 | * Add no weekends rule. 252 | */ 253 | public function noWeekends(): self 254 | { 255 | return $this->withRule('no_weekends'); 256 | } 257 | 258 | /** 259 | * Add custom metadata. 260 | */ 261 | public function withMetadata(array $metadata): self 262 | { 263 | $this->attributes['metadata'] = array_merge($this->attributes['metadata'] ?? [], $metadata); 264 | 265 | return $this; 266 | } 267 | 268 | /** 269 | * Set the schedule as inactive. 270 | */ 271 | public function inactive(): self 272 | { 273 | $this->attributes['is_active'] = false; 274 | 275 | return $this; 276 | } 277 | 278 | /** 279 | * Set the schedule as active (default). 280 | */ 281 | public function active(): self 282 | { 283 | $this->attributes['is_active'] = true; 284 | 285 | return $this; 286 | } 287 | 288 | /** 289 | * Build and validate the schedule without saving. 290 | */ 291 | public function build(): array 292 | { 293 | if (! $this->schedulable) { 294 | throw new \InvalidArgumentException('Schedulable model must be set using for() method'); 295 | } 296 | 297 | if (empty($this->attributes['start_date'])) { 298 | throw new \InvalidArgumentException('Start date must be set using from() method'); 299 | } 300 | 301 | // Set default schedule_type if not specified 302 | if (! isset($this->attributes['schedule_type'])) { 303 | $this->attributes['schedule_type'] = ScheduleTypes::CUSTOM; 304 | } 305 | 306 | return [ 307 | 'schedulable' => $this->schedulable, 308 | 'attributes' => $this->attributes, 309 | 'periods' => $this->periods, 310 | 'rules' => $this->rules, 311 | ]; 312 | } 313 | 314 | /** 315 | * Save the schedule. 316 | */ 317 | public function save(): Schedule 318 | { 319 | $built = $this->build(); 320 | 321 | return app(ScheduleService::class)->create( 322 | $built['schedulable'], 323 | $built['attributes'], 324 | $built['periods'], 325 | $built['rules'] 326 | ); 327 | } 328 | 329 | /** 330 | * Get the current attributes. 331 | */ 332 | public function getAttributes(): array 333 | { 334 | return $this->attributes; 335 | } 336 | 337 | /** 338 | * Get the current periods. 339 | */ 340 | public function getPeriods(): array 341 | { 342 | return $this->periods; 343 | } 344 | 345 | /** 346 | * Get the current rules. 347 | */ 348 | public function getRules(): array 349 | { 350 | return $this->rules; 351 | } 352 | 353 | /** 354 | * Reset the builder to start fresh. 355 | */ 356 | public function reset(): self 357 | { 358 | $this->schedulable = null; 359 | $this->attributes = []; 360 | $this->periods = []; 361 | $this->rules = []; 362 | 363 | return $this; 364 | } 365 | 366 | /** 367 | * Clone the builder with the same configuration. 368 | */ 369 | public function clone(): self 370 | { 371 | $clone = new self; 372 | $clone->schedulable = $this->schedulable; 373 | $clone->attributes = $this->attributes; 374 | $clone->periods = $this->periods; 375 | $clone->rules = $this->rules; 376 | 377 | return $clone; 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /src/Enums/ScheduleTypes.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Enums; 4 | 5 | enum ScheduleTypes: string 6 | { 7 | case AVAILABILITY = 'availability'; 8 | 9 | case APPOINTMENT = 'appointment'; 10 | 11 | case BLOCKED = 'blocked'; 12 | 13 | case CUSTOM = 'custom'; 14 | 15 | /** 16 | * Get all available schedule types. 17 | * 18 | * @return string[] 19 | */ 20 | public static function values(): array 21 | { 22 | return collect(self::cases()) 23 | ->map(fn (ScheduleTypes $type): string => $type->value) 24 | ->all(); 25 | } 26 | 27 | /** 28 | * Check this schedule type is of a specific availability type. 29 | */ 30 | public function is(ScheduleTypes $type): bool 31 | { 32 | return $this->value === $type->value; 33 | } 34 | 35 | /** 36 | * Get the types that allow overlaps. 37 | */ 38 | public function allowsOverlaps(): bool 39 | { 40 | return match ($this) { 41 | self::AVAILABILITY, self::CUSTOM => true, 42 | default => false, 43 | }; 44 | } 45 | 46 | /** 47 | * Get types that prevent overlaps. 48 | */ 49 | public function preventsOverlaps(): bool 50 | { 51 | return match ($this) { 52 | self::APPOINTMENT, self::BLOCKED => true, 53 | default => false, 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Events/ScheduleCreated.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Events; 4 | 5 | use Illuminate\Foundation\Events\Dispatchable; 6 | use Illuminate\Queue\SerializesModels; 7 | use Zap\Models\Schedule; 8 | 9 | class ScheduleCreated 10 | { 11 | use Dispatchable, SerializesModels; 12 | 13 | /** 14 | * Create a new event instance. 15 | */ 16 | public function __construct( 17 | public Schedule $schedule 18 | ) {} 19 | 20 | /** 21 | * Get the schedule that was created. 22 | */ 23 | public function getSchedule(): Schedule 24 | { 25 | return $this->schedule; 26 | } 27 | 28 | /** 29 | * Get the schedulable model. 30 | */ 31 | public function getSchedulable() 32 | { 33 | return $this->schedule->schedulable; 34 | } 35 | 36 | /** 37 | * Check if the schedule is recurring. 38 | */ 39 | public function isRecurring(): bool 40 | { 41 | return $this->schedule->is_recurring; 42 | } 43 | 44 | /** 45 | * Get the event as an array. 46 | */ 47 | public function toArray(): array 48 | { 49 | return [ 50 | 'schedule_id' => $this->schedule->id, 51 | 'schedulable_type' => $this->schedule->schedulable_type, 52 | 'schedulable_id' => $this->schedule->schedulable_id, 53 | 'name' => $this->schedule->name, 54 | 'start_date' => $this->schedule->start_date->toDateString(), 55 | 'end_date' => $this->schedule->end_date?->toDateString(), 56 | 'is_recurring' => $this->schedule->is_recurring, 57 | 'frequency' => $this->schedule->frequency, 58 | 'created_at' => $this->schedule->created_at->toISOString(), 59 | ]; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidScheduleException.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Exceptions; 4 | 5 | class InvalidScheduleException extends ZapException 6 | { 7 | /** 8 | * The validation errors. 9 | */ 10 | protected array $errors = []; 11 | 12 | /** 13 | * Create a new invalid schedule exception. 14 | */ 15 | public function __construct( 16 | string $message = 'Invalid schedule data provided', 17 | int $code = 422, 18 | ?\Throwable $previous = null 19 | ) { 20 | parent::__construct($message, $code, $previous); 21 | } 22 | 23 | /** 24 | * Set the validation errors. 25 | */ 26 | public function setErrors(array $errors): self 27 | { 28 | $this->errors = $errors; 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * Get the validation errors. 35 | */ 36 | public function getErrors(): array 37 | { 38 | return $this->errors; 39 | } 40 | 41 | /** 42 | * Get the exception as an array with validation errors. 43 | */ 44 | public function toArray(): array 45 | { 46 | return array_merge(parent::toArray(), [ 47 | 'errors' => $this->getErrors(), 48 | 'error_count' => count($this->errors), 49 | ]); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Exceptions/ScheduleConflictException.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Exceptions; 4 | 5 | class ScheduleConflictException extends ZapException 6 | { 7 | /** 8 | * The conflicting schedules. 9 | */ 10 | protected array $conflictingSchedules = []; 11 | 12 | /** 13 | * Create a new schedule conflict exception. 14 | */ 15 | public function __construct( 16 | string $message = 'Schedule conflicts detected', 17 | int $code = 409, 18 | ?\Throwable $previous = null 19 | ) { 20 | parent::__construct($message, $code, $previous); 21 | } 22 | 23 | /** 24 | * Set the conflicting schedules. 25 | */ 26 | public function setConflictingSchedules(array $schedules): self 27 | { 28 | $this->conflictingSchedules = $schedules; 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * Get the conflicting schedules. 35 | */ 36 | public function getConflictingSchedules(): array 37 | { 38 | return $this->conflictingSchedules; 39 | } 40 | 41 | /** 42 | * Get the exception as an array with conflict details. 43 | */ 44 | public function toArray(): array 45 | { 46 | return array_merge(parent::toArray(), [ 47 | 'conflicting_schedules' => $this->getConflictingSchedules(), 48 | 'conflict_count' => count($this->conflictingSchedules), 49 | ]); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Exceptions/ZapException.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Exceptions; 4 | 5 | use Exception; 6 | 7 | abstract class ZapException extends Exception 8 | { 9 | /** 10 | * The error context data. 11 | */ 12 | protected array $context = []; 13 | 14 | /** 15 | * Set context data for the exception. 16 | */ 17 | public function setContext(array $context): self 18 | { 19 | $this->context = $context; 20 | 21 | return $this; 22 | } 23 | 24 | /** 25 | * Get the error context. 26 | */ 27 | public function getContext(): array 28 | { 29 | return $this->context; 30 | } 31 | 32 | /** 33 | * Get the exception as an array. 34 | */ 35 | public function toArray(): array 36 | { 37 | return [ 38 | 'message' => $this->getMessage(), 39 | 'code' => $this->getCode(), 40 | 'file' => $this->getFile(), 41 | 'line' => $this->getLine(), 42 | 'context' => $this->getContext(), 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Facades/Zap.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Facades; 4 | 5 | use Illuminate\Support\Facades\Facade; 6 | use Zap\Builders\ScheduleBuilder; 7 | 8 | /** 9 | * @method static ScheduleBuilder for(mixed $schedulable) 10 | * @method static ScheduleBuilder schedule() 11 | * @method static array findConflicts(\Zap\Models\Schedule $schedule) 12 | * @method static bool hasConflicts(\Zap\Models\Schedule $schedule) 13 | * 14 | * @see \Zap\Services\ScheduleService 15 | */ 16 | class Zap extends Facade 17 | { 18 | /** 19 | * Get the registered name of the component. 20 | */ 21 | protected static function getFacadeAccessor(): string 22 | { 23 | return 'zap'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Models/Concerns/HasSchedules.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Models\Concerns; 4 | 5 | use Illuminate\Database\Eloquent\Relations\MorphMany; 6 | use Zap\Builders\ScheduleBuilder; 7 | use Zap\Enums\ScheduleTypes; 8 | use Zap\Models\Schedule; 9 | use Zap\Services\ConflictDetectionService; 10 | 11 | /** 12 | * Trait HasSchedules 13 | * 14 | * This trait provides scheduling capabilities to any Eloquent model. 15 | * Use this trait in models that need to be schedulable. 16 | * 17 | * @mixin \Illuminate\Database\Eloquent\Model 18 | */ 19 | trait HasSchedules 20 | { 21 | /** 22 | * Get all schedules for this model. 23 | * 24 | * @return MorphMany<Schedule, $this> 25 | */ 26 | public function schedules(): MorphMany 27 | { 28 | return $this->morphMany(Schedule::class, 'schedulable'); 29 | } 30 | 31 | /** 32 | * Get only active schedules. 33 | * 34 | * @return MorphMany<Schedule, $this> 35 | */ 36 | public function activeSchedules(): MorphMany 37 | { 38 | return $this->schedules()->active(); 39 | } 40 | 41 | /** 42 | * Get availability schedules. 43 | * 44 | * @return MorphMany<Schedule, $this> 45 | */ 46 | public function availabilitySchedules(): MorphMany 47 | { 48 | return $this->schedules()->availability(); 49 | } 50 | 51 | /** 52 | * Get appointment schedules. 53 | * 54 | * @return MorphMany<Schedule, $this> 55 | */ 56 | public function appointmentSchedules(): MorphMany 57 | { 58 | return $this->schedules()->appointments(); 59 | } 60 | 61 | /** 62 | * Get blocked schedules. 63 | * 64 | * @return MorphMany<Schedule, $this> 65 | */ 66 | public function blockedSchedules(): MorphMany 67 | { 68 | return $this->schedules()->blocked(); 69 | } 70 | 71 | /** 72 | * Get schedules for a specific date. 73 | * 74 | * @return MorphMany<Schedule, $this> 75 | */ 76 | public function schedulesForDate(string $date): MorphMany 77 | { 78 | return $this->schedules()->forDate($date); 79 | } 80 | 81 | /** 82 | * Get schedules within a date range. 83 | * 84 | * @return MorphMany<Schedule, $this> 85 | */ 86 | public function schedulesForDateRange(string $startDate, string $endDate): MorphMany 87 | { 88 | return $this->schedules()->forDateRange($startDate, $endDate); 89 | } 90 | 91 | /** 92 | * Get recurring schedules. 93 | * 94 | * @return MorphMany<Schedule, $this> 95 | */ 96 | public function recurringSchedules(): MorphMany 97 | { 98 | return $this->schedules()->recurring(); 99 | } 100 | 101 | /** 102 | * Get schedules of a specific type. 103 | * 104 | * @return MorphMany<Schedule, $this> 105 | */ 106 | public function schedulesOfType(string $type): MorphMany 107 | { 108 | return $this->schedules()->ofType($type); 109 | } 110 | 111 | /** 112 | * Create a new schedule builder for this model. 113 | */ 114 | public function createSchedule(): ScheduleBuilder 115 | { 116 | return (new ScheduleBuilder)->for($this); 117 | } 118 | 119 | /** 120 | * Check if this model has any schedule conflicts with the given schedule. 121 | */ 122 | public function hasScheduleConflict(Schedule $schedule): bool 123 | { 124 | return app(ConflictDetectionService::class)->hasConflicts($schedule); 125 | } 126 | 127 | /** 128 | * Find all schedules that conflict with the given schedule. 129 | */ 130 | public function findScheduleConflicts(Schedule $schedule): array 131 | { 132 | return app(ConflictDetectionService::class)->findConflicts($schedule); 133 | } 134 | 135 | /** 136 | * Check if this model is available during a specific time period. 137 | */ 138 | public function isAvailableAt(string $date, string $startTime, string $endTime): bool 139 | { 140 | // Get all active schedules for this model on this date 141 | $schedules = \Zap\Models\Schedule::where('schedulable_type', get_class($this)) 142 | ->where('schedulable_id', $this->getKey()) 143 | ->active() 144 | ->forDate($date) 145 | ->with('periods') 146 | ->get(); 147 | 148 | foreach ($schedules as $schedule) { 149 | $shouldBlock = $schedule->schedule_type === null 150 | || $schedule->schedule_type->is(ScheduleTypes::CUSTOM) 151 | || $schedule->preventsOverlaps(); 152 | 153 | if ($shouldBlock && $this->scheduleBlocksTime($schedule, $date, $startTime, $endTime)) { 154 | return false; 155 | } 156 | } 157 | 158 | return true; 159 | } 160 | 161 | /** 162 | * Check if a specific schedule blocks the given time period. 163 | */ 164 | protected function scheduleBlocksTime(\Zap\Models\Schedule $schedule, string $date, string $startTime, string $endTime): bool 165 | { 166 | if (! $schedule->isActiveOn($date)) { 167 | return false; 168 | } 169 | 170 | if ($schedule->is_recurring) { 171 | return $this->recurringScheduleBlocksTime($schedule, $date, $startTime, $endTime); 172 | } 173 | 174 | // For non-recurring schedules, check stored periods 175 | return $schedule->periods()->overlapping($date, $startTime, $endTime)->exists(); 176 | } 177 | 178 | /** 179 | * Check if a recurring schedule blocks the given time period. 180 | */ 181 | protected function recurringScheduleBlocksTime(\Zap\Models\Schedule $schedule, string $date, string $startTime, string $endTime): bool 182 | { 183 | $checkDate = \Carbon\Carbon::parse($date); 184 | 185 | // Check if this date should have a recurring instance 186 | if (! $this->shouldCreateRecurringInstance($schedule, $checkDate)) { 187 | return false; 188 | } 189 | 190 | // Get the base periods and check if any would overlap on this date 191 | $basePeriods = $schedule->periods; 192 | 193 | foreach ($basePeriods as $basePeriod) { 194 | if ($this->timePeriodsOverlap($basePeriod->start_time, $basePeriod->end_time, $startTime, $endTime)) { 195 | return true; 196 | } 197 | } 198 | 199 | return false; 200 | } 201 | 202 | /** 203 | * Check if a recurring instance should be created for the given date. 204 | */ 205 | protected function shouldCreateRecurringInstance(\Zap\Models\Schedule $schedule, \Carbon\Carbon $date): bool 206 | { 207 | $frequency = $schedule->frequency; 208 | $config = $schedule->frequency_config ?? []; 209 | 210 | switch ($frequency) { 211 | case 'daily': 212 | return true; 213 | 214 | case 'weekly': 215 | $allowedDays = $config['days'] ?? ['monday']; 216 | $allowedDayNumbers = array_map(function ($day) { 217 | return match (strtolower($day)) { 218 | 'sunday' => 0, 219 | 'monday' => 1, 220 | 'tuesday' => 2, 221 | 'wednesday' => 3, 222 | 'thursday' => 4, 223 | 'friday' => 5, 224 | 'saturday' => 6, 225 | default => 1, // Default to Monday 226 | }; 227 | }, $allowedDays); 228 | 229 | return in_array($date->dayOfWeek, $allowedDayNumbers); 230 | 231 | case 'monthly': 232 | $dayOfMonth = $config['day_of_month'] ?? $schedule->start_date->day; 233 | 234 | return $date->day === $dayOfMonth; 235 | 236 | default: 237 | return false; 238 | } 239 | } 240 | 241 | /** 242 | * Check if two time periods overlap. 243 | */ 244 | protected function timePeriodsOverlap(string $start1, string $end1, string $start2, string $end2): bool 245 | { 246 | return $start1 < $end2 && $end1 > $start2; 247 | } 248 | 249 | /** 250 | * Get available time slots for a specific date. 251 | */ 252 | public function getAvailableSlots( 253 | string $date, 254 | string $dayStart = '09:00', 255 | string $dayEnd = '17:00', 256 | int $slotDuration = 60 257 | ): array { 258 | // Validate inputs to prevent infinite loops 259 | if ($slotDuration <= 0) { 260 | return []; 261 | } 262 | 263 | $slots = []; 264 | $currentTime = \Carbon\Carbon::parse($date.' '.$dayStart); 265 | $endTime = \Carbon\Carbon::parse($date.' '.$dayEnd); 266 | 267 | // If end time is before or equal to start time, return empty array 268 | if ($endTime->lessThanOrEqualTo($currentTime)) { 269 | return []; 270 | } 271 | 272 | // Safety counter to prevent infinite loops (max 1440 minutes in a day / min slot duration) 273 | $maxIterations = 1440; 274 | $iterations = 0; 275 | 276 | while ($currentTime->lessThan($endTime) && $iterations < $maxIterations) { 277 | $slotEnd = $currentTime->copy()->addMinutes($slotDuration); 278 | 279 | if ($slotEnd->lessThanOrEqualTo($endTime)) { 280 | $isAvailable = $this->isAvailableAt( 281 | $date, 282 | $currentTime->format('H:i'), 283 | $slotEnd->format('H:i') 284 | ); 285 | 286 | $slots[] = [ 287 | 'start_time' => $currentTime->format('H:i'), 288 | 'end_time' => $slotEnd->format('H:i'), 289 | 'is_available' => $isAvailable, 290 | ]; 291 | } 292 | 293 | $currentTime->addMinutes($slotDuration); 294 | $iterations++; 295 | } 296 | 297 | return $slots; 298 | } 299 | 300 | /** 301 | * Get the next available time slot. 302 | */ 303 | public function getNextAvailableSlot( 304 | ?string $afterDate = null, 305 | int $duration = 60, 306 | string $dayStart = '09:00', 307 | string $dayEnd = '17:00' 308 | ): ?array { 309 | // Validate inputs 310 | if ($duration <= 0) { 311 | return null; 312 | } 313 | 314 | $startDate = $afterDate ?? now()->format('Y-m-d'); 315 | $checkDate = \Carbon\Carbon::parse($startDate); 316 | 317 | // Check up to 30 days in the future 318 | for ($i = 0; $i < 30; $i++) { 319 | $dateString = $checkDate->format('Y-m-d'); 320 | $slots = $this->getAvailableSlots($dateString, $dayStart, $dayEnd, $duration); 321 | 322 | foreach ($slots as $slot) { 323 | if ($slot['is_available']) { 324 | return array_merge($slot, ['date' => $dateString]); 325 | } 326 | } 327 | 328 | $checkDate->addDay(); 329 | } 330 | 331 | return null; 332 | } 333 | 334 | /** 335 | * Count total scheduled time for a date range. 336 | */ 337 | public function getTotalScheduledTime(string $startDate, string $endDate): int 338 | { 339 | return $this->schedules() 340 | ->active() 341 | ->forDateRange($startDate, $endDate) 342 | ->with('periods') 343 | ->get() 344 | ->sum(function ($schedule) { 345 | return $schedule->periods->sum('duration_minutes'); 346 | }); 347 | } 348 | 349 | /** 350 | * Check if the model has any schedules. 351 | */ 352 | public function hasSchedules(): bool 353 | { 354 | return $this->schedules()->exists(); 355 | } 356 | 357 | /** 358 | * Check if the model has any active schedules. 359 | */ 360 | public function hasActiveSchedules(): bool 361 | { 362 | return $this->activeSchedules()->exists(); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/Models/Schedule.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Models; 4 | 5 | use Carbon\Carbon; 6 | use Illuminate\Database\Eloquent\Builder; 7 | use Illuminate\Database\Eloquent\Collection; 8 | use Illuminate\Database\Eloquent\Model; 9 | use Illuminate\Database\Eloquent\Relations\HasMany; 10 | use Illuminate\Database\Eloquent\Relations\MorphTo; 11 | use Zap\Enums\ScheduleTypes; 12 | 13 | /** 14 | * @property int $id 15 | * @property string $name 16 | * @property string|null $description 17 | * @property string $schedulable_type 18 | * @property int $schedulable_id 19 | * @property ScheduleTypes $schedule_type 20 | * @property Carbon $start_date 21 | * @property Carbon|null $end_date 22 | * @property bool $is_recurring 23 | * @property string|null $frequency 24 | * @property array|null $frequency_config 25 | * @property array|null $metadata 26 | * @property bool $is_active 27 | * @property Carbon|null $created_at 28 | * @property Carbon|null $updated_at 29 | * @property Carbon|null $deleted_at 30 | * @property-read \Illuminate\Database\Eloquent\Collection<int, SchedulePeriod> $periods 31 | * @property-read Model $schedulable 32 | * @property-read int $total_duration 33 | */ 34 | class Schedule extends Model 35 | { 36 | /** 37 | * The attributes that are mass assignable. 38 | */ 39 | protected $fillable = [ 40 | 'schedulable_type', 41 | 'schedulable_id', 42 | 'name', 43 | 'description', 44 | 'schedule_type', 45 | 'start_date', 46 | 'end_date', 47 | 'is_recurring', 48 | 'frequency', 49 | 'frequency_config', 50 | 'metadata', 51 | 'is_active', 52 | ]; 53 | 54 | /** 55 | * The attributes that should be cast. 56 | */ 57 | protected $casts = [ 58 | 'schedule_type' => ScheduleTypes::class, 59 | 'start_date' => 'date', 60 | 'end_date' => 'date', 61 | 'is_recurring' => 'boolean', 62 | 'frequency_config' => 'array', 63 | 'metadata' => 'array', 64 | 'is_active' => 'boolean', 65 | ]; 66 | 67 | /** 68 | * The attributes that should be guarded. 69 | */ 70 | protected $guarded = []; 71 | 72 | /** 73 | * Get the parent schedulable model. 74 | */ 75 | public function schedulable(): MorphTo 76 | { 77 | return $this->morphTo(); 78 | } 79 | 80 | /** 81 | * Get the schedule periods. 82 | * 83 | * @return HasMany<SchedulePeriod, $this> 84 | */ 85 | public function periods(): HasMany 86 | { 87 | return $this->hasMany(SchedulePeriod::class); 88 | } 89 | 90 | /** 91 | * Create a new Eloquent query builder for the model. 92 | */ 93 | public function newEloquentBuilder($query): Builder 94 | { 95 | return new Builder($query); 96 | } 97 | 98 | /** 99 | * Create a new Eloquent Collection instance. 100 | * 101 | * @param array<int, static> $models 102 | * @return \Illuminate\Database\Eloquent\Collection<int, static> 103 | */ 104 | public function newCollection(array $models = []): Collection 105 | { 106 | return new Collection($models); 107 | } 108 | 109 | /** 110 | * Scope a query to only include active schedules. 111 | */ 112 | public function scopeActive(Builder $query): void 113 | { 114 | $query->where('is_active', true); 115 | } 116 | 117 | /** 118 | * Scope a query to only include recurring schedules. 119 | */ 120 | public function scopeRecurring(Builder $query): void 121 | { 122 | $query->where('is_recurring', true); 123 | } 124 | 125 | /** 126 | * Scope a query to only include schedules of a specific type. 127 | */ 128 | public function scopeOfType(Builder $query, string $type): void 129 | { 130 | $query->where('schedule_type', $type); 131 | } 132 | 133 | /** 134 | * Scope a query to only include availability schedules. 135 | */ 136 | public function scopeAvailability(Builder $query): void 137 | { 138 | $query->where('schedule_type', ScheduleTypes::AVAILABILITY); 139 | } 140 | 141 | /** 142 | * Scope a query to only include appointment schedules. 143 | */ 144 | public function scopeAppointments(Builder $query): void 145 | { 146 | $query->where('schedule_type', ScheduleTypes::APPOINTMENT); 147 | } 148 | 149 | /** 150 | * Scope a query to only include blocked schedules. 151 | */ 152 | public function scopeBlocked(Builder $query): void 153 | { 154 | $query->where('schedule_type', ScheduleTypes::BLOCKED); 155 | } 156 | 157 | /** 158 | * Scope a query to only include schedules for a specific date. 159 | */ 160 | public function scopeForDate(Builder $query, string $date): void 161 | { 162 | $checkDate = \Carbon\Carbon::parse($date); 163 | 164 | $query->where('start_date', '<=', $checkDate) 165 | ->where(function ($q) use ($checkDate) { 166 | $q->whereNull('end_date') 167 | ->orWhere('end_date', '>=', $checkDate); 168 | }); 169 | } 170 | 171 | /** 172 | * Scope a query to only include schedules within a date range. 173 | */ 174 | public function scopeForDateRange(Builder $query, string $startDate, string $endDate): void 175 | { 176 | $query->where(function ($q) use ($startDate, $endDate) { 177 | $q->whereBetween('start_date', [$startDate, $endDate]) 178 | ->orWhereBetween('end_date', [$startDate, $endDate]) 179 | ->orWhere(function ($q2) use ($startDate, $endDate) { 180 | $q2->where('start_date', '<=', $startDate) 181 | ->where('end_date', '>=', $endDate); 182 | }); 183 | }); 184 | } 185 | 186 | /** 187 | * Check if this schedule overlaps with another schedule. 188 | */ 189 | public function overlapsWith(Schedule $other): bool 190 | { 191 | // Basic date range overlap check 192 | if ($this->end_date && $other->end_date) { 193 | return $this->start_date <= $other->end_date && $this->end_date >= $other->start_date; 194 | } 195 | 196 | // Handle open-ended schedules 197 | if (! $this->end_date && ! $other->end_date) { 198 | return $this->start_date <= $other->start_date; 199 | } 200 | 201 | if (! $this->end_date) { 202 | return $this->start_date <= ($other->end_date ?? $other->start_date); 203 | } 204 | 205 | if (! $other->end_date) { 206 | return $this->end_date >= $other->start_date; 207 | } 208 | 209 | return false; 210 | } 211 | 212 | /** 213 | * Get the total duration of all periods in minutes. 214 | */ 215 | public function getTotalDurationAttribute(): int 216 | { 217 | return $this->periods->sum('duration_minutes'); 218 | } 219 | 220 | /** 221 | * Check if the schedule is currently active. 222 | */ 223 | public function isActiveOn(string $date): bool 224 | { 225 | if (! $this->is_active) { 226 | return false; 227 | } 228 | 229 | $checkDate = \Carbon\Carbon::parse($date); 230 | $startDate = $this->start_date; 231 | $endDate = $this->end_date; 232 | 233 | return $checkDate->greaterThanOrEqualTo($startDate) && 234 | ($endDate === null || $checkDate->lessThanOrEqualTo($endDate)); 235 | } 236 | 237 | /** 238 | * Check if this schedule is of availability type. 239 | */ 240 | public function isAvailability(): bool 241 | { 242 | return $this->schedule_type->is(ScheduleTypes::AVAILABILITY); 243 | } 244 | 245 | /** 246 | * Check if this schedule is of appointment type. 247 | */ 248 | public function isAppointment(): bool 249 | { 250 | return $this->schedule_type->is(ScheduleTypes::APPOINTMENT); 251 | } 252 | 253 | /** 254 | * Check if this schedule is of blocked type. 255 | */ 256 | public function isBlocked(): bool 257 | { 258 | return $this->schedule_type->is(ScheduleTypes::BLOCKED); 259 | } 260 | 261 | /** 262 | * Check if this schedule is of custom type. 263 | */ 264 | public function isCustom(): bool 265 | { 266 | return $this->schedule_type->is(ScheduleTypes::CUSTOM); 267 | } 268 | 269 | /** 270 | * Check if this schedule should prevent overlaps (appointments and blocked schedules). 271 | */ 272 | public function preventsOverlaps(): bool 273 | { 274 | return $this->schedule_type->preventsOverlaps(); 275 | } 276 | 277 | /** 278 | * Check if this schedule allows overlaps (availability schedules). 279 | */ 280 | public function allowsOverlaps(): bool 281 | { 282 | return $this->schedule_type->allowsOverlaps(); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/Models/SchedulePeriod.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Models; 4 | 5 | use Carbon\Carbon; 6 | use Illuminate\Database\Eloquent\Model; 7 | use Illuminate\Database\Eloquent\Relations\BelongsTo; 8 | 9 | /** 10 | * @property int $id 11 | * @property int $schedule_id 12 | * @property Carbon $date 13 | * @property Carbon|null $start_time 14 | * @property Carbon|null $end_time 15 | * @property bool $is_available 16 | * @property array|null $metadata 17 | * @property Carbon|null $created_at 18 | * @property Carbon|null $updated_at 19 | * @property-read Schedule $schedule 20 | * @property-read int $duration_minutes 21 | * @property-read Carbon $start_date_time 22 | * @property-read Carbon $end_date_time 23 | */ 24 | class SchedulePeriod extends Model 25 | { 26 | /** 27 | * The attributes that are mass assignable. 28 | */ 29 | protected $fillable = [ 30 | 'schedule_id', 31 | 'date', 32 | 'start_time', 33 | 'end_time', 34 | 'is_available', 35 | 'metadata', 36 | ]; 37 | 38 | /** 39 | * The attributes that should be cast. 40 | */ 41 | protected $casts = [ 42 | 'date' => 'date', 43 | 'start_time' => 'string', 44 | 'end_time' => 'string', 45 | 'is_available' => 'boolean', 46 | 'metadata' => 'array', 47 | ]; 48 | 49 | /** 50 | * Get the schedule that owns the period. 51 | */ 52 | public function schedule(): BelongsTo 53 | { 54 | return $this->belongsTo(Schedule::class); 55 | } 56 | 57 | /** 58 | * Get the duration in minutes. 59 | */ 60 | public function getDurationMinutesAttribute(): int 61 | { 62 | if (! $this->start_time || ! $this->end_time) { 63 | return 0; 64 | } 65 | 66 | $baseDate = '2024-01-01'; // Use a consistent base date for time parsing 67 | $start = Carbon::parse($baseDate.' '.$this->start_time); 68 | $end = Carbon::parse($baseDate.' '.$this->end_time); 69 | 70 | return (int) $start->diffInMinutes($end); 71 | } 72 | 73 | /** 74 | * Get the full start datetime. 75 | */ 76 | public function getStartDateTimeAttribute(): Carbon 77 | { 78 | return Carbon::parse($this->date->format('Y-m-d').' '.$this->start_time); 79 | } 80 | 81 | /** 82 | * Get the full end datetime. 83 | */ 84 | public function getEndDateTimeAttribute(): Carbon 85 | { 86 | return Carbon::parse($this->date->format('Y-m-d').' '.$this->end_time); 87 | } 88 | 89 | /** 90 | * Check if this period overlaps with another period. 91 | */ 92 | public function overlapsWith(SchedulePeriod $other): bool 93 | { 94 | // Must be on the same date 95 | if (! $this->date->eq($other->date)) { 96 | return false; 97 | } 98 | 99 | return $this->start_time < $other->end_time && $this->end_time > $other->start_time; 100 | } 101 | 102 | /** 103 | * Check if this period is currently active (happening now). 104 | */ 105 | public function isActiveNow(): bool 106 | { 107 | $now = Carbon::now(); 108 | $startDateTime = $this->start_date_time; 109 | $endDateTime = $this->end_date_time; 110 | 111 | return $now->between($startDateTime, $endDateTime); 112 | } 113 | 114 | /** 115 | * Scope a query to only include available periods. 116 | */ 117 | public function scopeAvailable(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder 118 | { 119 | return $query->where('is_available', true); 120 | } 121 | 122 | /** 123 | * Scope a query to only include periods for a specific date. 124 | */ 125 | public function scopeForDate(\Illuminate\Database\Eloquent\Builder $query, string $date): \Illuminate\Database\Eloquent\Builder 126 | { 127 | return $query->where('date', $date); 128 | } 129 | 130 | /** 131 | * Scope a query to only include periods within a time range. 132 | */ 133 | public function scopeForTimeRange(\Illuminate\Database\Eloquent\Builder $query, string $startTime, string $endTime): \Illuminate\Database\Eloquent\Builder 134 | { 135 | return $query->where('start_time', '>=', $startTime) 136 | ->where('end_time', '<=', $endTime); 137 | } 138 | 139 | /** 140 | * Scope a query to find overlapping periods. 141 | */ 142 | public function scopeOverlapping(\Illuminate\Database\Eloquent\Builder $query, string $date, string $startTime, string $endTime): \Illuminate\Database\Eloquent\Builder 143 | { 144 | return $query->whereDate('date', $date) 145 | ->where('start_time', '<', $endTime) 146 | ->where('end_time', '>', $startTime); 147 | } 148 | 149 | /** 150 | * Convert the period to a human-readable string. 151 | */ 152 | public function __toString(): string 153 | { 154 | return sprintf( 155 | '%s from %s to %s', 156 | $this->date->format('Y-m-d'), 157 | $this->start_time, 158 | $this->end_time 159 | ); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Services/ConflictDetectionService.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Services; 4 | 5 | use Illuminate\Database\Eloquent\Model; 6 | use Illuminate\Support\Collection; 7 | use Zap\Enums\ScheduleTypes; 8 | use Zap\Models\Schedule; 9 | use Zap\Models\SchedulePeriod; 10 | 11 | class ConflictDetectionService 12 | { 13 | /** 14 | * Check if a schedule has conflicts with existing schedules. 15 | */ 16 | public function hasConflicts(Schedule $schedule): bool 17 | { 18 | return ! empty($this->findConflicts($schedule)); 19 | } 20 | 21 | /** 22 | * Find all schedules that conflict with the given schedule. 23 | */ 24 | public function findConflicts(Schedule $schedule): array 25 | { 26 | if (! config('zap.conflict_detection.enabled', true)) { 27 | return []; 28 | } 29 | 30 | $conflicts = []; 31 | $bufferMinutes = config('zap.conflict_detection.buffer_minutes', 0); 32 | 33 | // Get all other active schedules for the same schedulable 34 | $otherSchedules = $this->getOtherSchedules($schedule); 35 | 36 | foreach ($otherSchedules as $otherSchedule) { 37 | // Check conflicts based on schedule types and rules 38 | $shouldCheckConflict = $this->shouldCheckConflict($schedule, $otherSchedule); 39 | 40 | if ($shouldCheckConflict && $this->schedulesOverlap($schedule, $otherSchedule, $bufferMinutes)) { 41 | $conflicts[] = $otherSchedule; 42 | } 43 | } 44 | 45 | return $conflicts; 46 | } 47 | 48 | /** 49 | * Determine if two schedules should be checked for conflicts. 50 | */ 51 | protected function shouldCheckConflict(Schedule $schedule1, Schedule $schedule2): bool 52 | { 53 | // Availability schedules never conflict with anything (they allow overlaps) 54 | if ($schedule1->schedule_type->is(ScheduleTypes::AVAILABILITY) || 55 | $schedule2->schedule_type->is(ScheduleTypes::AVAILABILITY)) { 56 | return false; 57 | } 58 | 59 | // Check if no_overlap rule is enabled and applies to these schedule types 60 | $noOverlapConfig = config('zap.default_rules.no_overlap', []); 61 | if (! ($noOverlapConfig['enabled'] ?? true)) { 62 | return false; 63 | } 64 | 65 | $appliesTo = $noOverlapConfig['applies_to'] ?? [ScheduleTypes::APPOINTMENT->value, ScheduleTypes::BLOCKED->value]; 66 | $schedule1ShouldCheck = in_array($schedule1->schedule_type->value, $appliesTo); 67 | $schedule2ShouldCheck = in_array($schedule2->schedule_type->value, $appliesTo); 68 | 69 | // Both schedules must be of types that should be checked for conflicts 70 | return $schedule1ShouldCheck && $schedule2ShouldCheck; 71 | } 72 | 73 | /** 74 | * Check if a schedulable has conflicts with a given schedule. 75 | */ 76 | public function hasSchedulableConflicts(Model $schedulable, Schedule $schedule): bool 77 | { 78 | $conflicts = $this->findSchedulableConflicts($schedulable, $schedule); 79 | 80 | return ! empty($conflicts); 81 | } 82 | 83 | /** 84 | * Find conflicts for a schedulable with a given schedule. 85 | */ 86 | public function findSchedulableConflicts(Model $schedulable, Schedule $schedule): array 87 | { 88 | // Create a temporary schedule for conflict checking 89 | $tempSchedule = new Schedule([ 90 | 'schedulable_type' => get_class($schedulable), 91 | 'schedulable_id' => $schedulable->getKey(), 92 | 'start_date' => $schedule->start_date, 93 | 'end_date' => $schedule->end_date, 94 | 'is_active' => true, 95 | ]); 96 | 97 | // Copy periods if they exist 98 | if ($schedule->relationLoaded('periods')) { 99 | $tempSchedule->setRelation('periods', $schedule->periods); 100 | } 101 | 102 | return $this->findConflicts($tempSchedule); 103 | } 104 | 105 | /** 106 | * Check if two schedules overlap. 107 | */ 108 | public function schedulesOverlap( 109 | Schedule $schedule1, 110 | Schedule $schedule2, 111 | int $bufferMinutes = 0 112 | ): bool { 113 | // First check date range overlap 114 | if (! $this->dateRangesOverlap($schedule1, $schedule2)) { 115 | return false; 116 | } 117 | 118 | // Then check period-level conflicts 119 | return $this->periodsOverlap($schedule1, $schedule2, $bufferMinutes); 120 | } 121 | 122 | /** 123 | * Check if two schedules have overlapping date ranges. 124 | */ 125 | protected function dateRangesOverlap(Schedule $schedule1, Schedule $schedule2): bool 126 | { 127 | $start1 = $schedule1->start_date; 128 | $end1 = $schedule1->end_date ?? \Carbon\Carbon::parse('2099-12-31'); 129 | $start2 = $schedule2->start_date; 130 | $end2 = $schedule2->end_date ?? \Carbon\Carbon::parse('2099-12-31'); 131 | 132 | return $start1 <= $end2 && $end1 >= $start2; 133 | } 134 | 135 | /** 136 | * Check if periods from two schedules overlap. 137 | */ 138 | protected function periodsOverlap( 139 | Schedule $schedule1, 140 | Schedule $schedule2, 141 | int $bufferMinutes = 0 142 | ): bool { 143 | $periods1 = $this->getSchedulePeriods($schedule1); 144 | $periods2 = $this->getSchedulePeriods($schedule2); 145 | 146 | foreach ($periods1 as $period1) { 147 | foreach ($periods2 as $period2) { 148 | if ($this->periodPairOverlaps($period1, $period2, $bufferMinutes)) { 149 | return true; 150 | } 151 | } 152 | } 153 | 154 | return false; 155 | } 156 | 157 | /** 158 | * Check if two specific periods overlap. 159 | */ 160 | protected function periodPairOverlaps( 161 | SchedulePeriod $period1, 162 | SchedulePeriod $period2, 163 | int $bufferMinutes = 0 164 | ): bool { 165 | // Must be on the same date 166 | if (! $period1->date->eq($period2->date)) { 167 | return false; 168 | } 169 | 170 | $start1 = $this->parseTime($period1->start_time); 171 | $end1 = $this->parseTime($period1->end_time); 172 | $start2 = $this->parseTime($period2->start_time); 173 | $end2 = $this->parseTime($period2->end_time); 174 | 175 | // Apply buffer 176 | if ($bufferMinutes > 0) { 177 | $start1->subMinutes($bufferMinutes); 178 | $end1->addMinutes($bufferMinutes); 179 | } 180 | 181 | return $start1 < $end2 && $end1 > $start2; 182 | } 183 | 184 | /** 185 | * Get periods for a schedule, handling recurring schedules. 186 | */ 187 | protected function getSchedulePeriods(Schedule $schedule): Collection 188 | { 189 | $periods = $schedule->relationLoaded('periods') 190 | ? $schedule->periods 191 | : $schedule->periods()->get(); 192 | 193 | // If this is a recurring schedule, we need to generate recurring instances 194 | if ($schedule->is_recurring) { 195 | return $this->generateRecurringPeriods($schedule, $periods); 196 | } 197 | 198 | return $periods; 199 | } 200 | 201 | /** 202 | * Generate recurring periods for a recurring schedule within a reasonable range. 203 | */ 204 | protected function generateRecurringPeriods(Schedule $schedule, Collection $basePeriods): Collection 205 | { 206 | if (! $schedule->is_recurring || $basePeriods->isEmpty()) { 207 | return $basePeriods; 208 | } 209 | 210 | $allPeriods = collect(); 211 | 212 | // Generate recurring instances for the next year to cover reasonable conflicts 213 | $startDate = $schedule->start_date; 214 | $endDate = $schedule->end_date ?? $startDate->copy()->addYear(); 215 | 216 | // Limit the range to avoid infinite generation 217 | $maxEndDate = $startDate->copy()->addYear(); 218 | if ($endDate->gt($maxEndDate)) { 219 | $endDate = $maxEndDate; 220 | } 221 | 222 | $current = $startDate->copy(); 223 | 224 | while ($current->lte($endDate)) { 225 | // Check if this date should have a recurring instance 226 | if ($this->shouldCreateRecurringInstance($schedule, $current)) { 227 | // Generate periods for this recurring date 228 | foreach ($basePeriods as $basePeriod) { 229 | $recurringPeriod = new \Zap\Models\SchedulePeriod([ 230 | 'schedule_id' => $schedule->id, 231 | 'date' => $current->toDateString(), 232 | 'start_time' => $basePeriod->start_time, 233 | 'end_time' => $basePeriod->end_time, 234 | 'is_available' => $basePeriod->is_available, 235 | 'metadata' => $basePeriod->metadata, 236 | ]); 237 | 238 | $allPeriods->push($recurringPeriod); 239 | } 240 | } 241 | 242 | $current = $this->getNextRecurrence($schedule, $current); 243 | 244 | if ($current->gt($endDate)) { 245 | break; 246 | } 247 | } 248 | 249 | return $allPeriods; 250 | } 251 | 252 | /** 253 | * Check if a recurring instance should be created for the given date. 254 | */ 255 | protected function shouldCreateRecurringInstance(Schedule $schedule, \Carbon\Carbon $date): bool 256 | { 257 | $frequency = $schedule->frequency; 258 | $config = $schedule->frequency_config ?? []; 259 | 260 | switch ($frequency) { 261 | case 'daily': 262 | return true; 263 | 264 | case 'weekly': 265 | $allowedDays = $config['days'] ?? ['monday']; 266 | $allowedDayNumbers = array_map(function ($day) { 267 | return match (strtolower($day)) { 268 | 'sunday' => 0, 269 | 'monday' => 1, 270 | 'tuesday' => 2, 271 | 'wednesday' => 3, 272 | 'thursday' => 4, 273 | 'friday' => 5, 274 | 'saturday' => 6, 275 | default => 1, // Default to Monday 276 | }; 277 | }, $allowedDays); 278 | 279 | return in_array($date->dayOfWeek, $allowedDayNumbers); 280 | 281 | case 'monthly': 282 | $dayOfMonth = $config['day_of_month'] ?? $schedule->start_date->day; 283 | 284 | return $date->day === $dayOfMonth; 285 | 286 | default: 287 | return false; 288 | } 289 | } 290 | 291 | /** 292 | * Get the next recurrence date for a recurring schedule. 293 | */ 294 | protected function getNextRecurrence(Schedule $schedule, \Carbon\Carbon $current): \Carbon\Carbon 295 | { 296 | $frequency = $schedule->frequency; 297 | $config = $schedule->frequency_config ?? []; 298 | 299 | switch ($frequency) { 300 | case 'daily': 301 | return $current->copy()->addDay(); 302 | 303 | case 'weekly': 304 | $allowedDays = $config['days'] ?? ['monday']; 305 | 306 | return $this->getNextWeeklyOccurrence($current, $allowedDays); 307 | 308 | case 'monthly': 309 | $dayOfMonth = $config['day_of_month'] ?? $current->day; 310 | 311 | return $current->copy()->addMonth()->day($dayOfMonth); 312 | 313 | default: 314 | return $current->copy()->addDay(); 315 | } 316 | } 317 | 318 | /** 319 | * Get the next weekly occurrence for the given days. 320 | */ 321 | protected function getNextWeeklyOccurrence(\Carbon\Carbon $current, array $allowedDays): \Carbon\Carbon 322 | { 323 | $next = $current->copy()->addDay(); 324 | 325 | // Convert day names to numbers (0 = Sunday, 1 = Monday, etc.) 326 | $allowedDayNumbers = array_map(function ($day) { 327 | return match (strtolower($day)) { 328 | 'sunday' => 0, 329 | 'monday' => 1, 330 | 'tuesday' => 2, 331 | 'wednesday' => 3, 332 | 'thursday' => 4, 333 | 'friday' => 5, 334 | 'saturday' => 6, 335 | default => 1, // Default to Monday 336 | }; 337 | }, $allowedDays); 338 | 339 | // Find the next allowed day 340 | while (! in_array($next->dayOfWeek, $allowedDayNumbers)) { 341 | $next->addDay(); 342 | 343 | // Prevent infinite loop 344 | if ($next->diffInDays($current) > 7) { 345 | break; 346 | } 347 | } 348 | 349 | return $next; 350 | } 351 | 352 | /** 353 | * Get other active schedules for the same schedulable. 354 | */ 355 | protected function getOtherSchedules(Schedule $schedule): Collection 356 | { 357 | return Schedule::where('schedulable_type', $schedule->schedulable_type) 358 | ->where('schedulable_id', $schedule->schedulable_id) 359 | ->where('id', '!=', $schedule->id) 360 | ->active() 361 | ->with('periods') 362 | ->get(); 363 | } 364 | 365 | /** 366 | * Parse a time string to Carbon instance. 367 | */ 368 | protected function parseTime(string $time): \Carbon\Carbon 369 | { 370 | $baseDate = '2024-01-01'; // Use a consistent base date for time parsing 371 | 372 | return \Carbon\Carbon::parse($baseDate.' '.$time); 373 | } 374 | 375 | /** 376 | * Get conflicts for a specific time period. 377 | */ 378 | public function findPeriodConflicts( 379 | Model $schedulable, 380 | string $date, 381 | string $startTime, 382 | string $endTime 383 | ): Collection { 384 | return Schedule::where('schedulable_type', get_class($schedulable)) 385 | ->where('schedulable_id', $schedulable->getKey()) 386 | ->active() 387 | ->forDate($date) 388 | ->whereHas('periods', function ($query) use ($date, $startTime, $endTime) { 389 | $query->whereDate('date', $date) 390 | ->where('start_time', '<', $endTime) 391 | ->where('end_time', '>', $startTime); 392 | }) 393 | ->with('periods') 394 | ->get(); 395 | } 396 | 397 | /** 398 | * Check if a specific time slot is available. 399 | */ 400 | public function isTimeSlotAvailable( 401 | Model $schedulable, 402 | string $date, 403 | string $startTime, 404 | string $endTime 405 | ): bool { 406 | return $this->findPeriodConflicts($schedulable, $date, $startTime, $endTime)->isEmpty(); 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /src/Services/ScheduleService.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Services; 4 | 5 | use Illuminate\Database\Eloquent\Model; 6 | use Illuminate\Support\Facades\DB; 7 | use Illuminate\Support\Facades\Event; 8 | use Zap\Builders\ScheduleBuilder; 9 | use Zap\Events\ScheduleCreated; 10 | use Zap\Exceptions\ScheduleConflictException; 11 | use Zap\Models\Schedule; 12 | 13 | class ScheduleService 14 | { 15 | public function __construct( 16 | private ValidationService $validator, 17 | private ConflictDetectionService $conflictService 18 | ) {} 19 | 20 | /** 21 | * Create a new schedule with validation and conflict detection. 22 | */ 23 | public function create( 24 | Model $schedulable, 25 | array $attributes, 26 | array $periods = [], 27 | array $rules = [] 28 | ): Schedule { 29 | return DB::transaction(function () use ($schedulable, $attributes, $periods, $rules) { 30 | // Set default values 31 | $attributes = array_merge([ 32 | 'is_active' => true, 33 | 'is_recurring' => false, 34 | ], $attributes); 35 | 36 | // Validate the schedule data 37 | $this->validator->validate($schedulable, $attributes, $periods, $rules); 38 | 39 | // Create the schedule 40 | $schedule = new Schedule($attributes); 41 | $schedule->schedulable_type = get_class($schedulable); 42 | $schedule->schedulable_id = $schedulable->getKey(); 43 | $schedule->save(); 44 | 45 | // Create periods if provided 46 | if (! empty($periods)) { 47 | foreach ($periods as $period) { 48 | $period['schedule_id'] = $schedule->id; 49 | $schedule->periods()->create($period); 50 | } 51 | } 52 | 53 | // Note: Conflict checking is now done during validation phase 54 | // No need to check again after creation 55 | 56 | // Fire the created event 57 | Event::dispatch(new ScheduleCreated($schedule)); 58 | 59 | return $schedule->load('periods'); 60 | }); 61 | } 62 | 63 | /** 64 | * Update an existing schedule. 65 | */ 66 | public function update(Schedule $schedule, array $attributes, array $periods = []): Schedule 67 | { 68 | return DB::transaction(function () use ($schedule, $attributes, $periods) { 69 | // Update the schedule attributes 70 | $schedule->update($attributes); 71 | 72 | // Update periods if provided 73 | if (! empty($periods)) { 74 | // Delete existing periods and create new ones 75 | $schedule->periods()->delete(); 76 | 77 | foreach ($periods as $period) { 78 | $period['schedule_id'] = $schedule->id; 79 | $schedule->periods()->create($period); 80 | } 81 | } 82 | 83 | // Check for conflicts after update 84 | $conflicts = $this->conflictService->findConflicts($schedule); 85 | if (! empty($conflicts)) { 86 | throw (new ScheduleConflictException( 87 | 'Updated schedule conflicts with existing schedules' 88 | ))->setConflictingSchedules($conflicts); 89 | } 90 | 91 | return $schedule->fresh('periods'); 92 | }); 93 | } 94 | 95 | /** 96 | * Delete a schedule. 97 | */ 98 | public function delete(Schedule $schedule): bool 99 | { 100 | return DB::transaction(function () use ($schedule) { 101 | // Delete all periods first 102 | $schedule->periods()->delete(); 103 | 104 | // Delete the schedule 105 | return $schedule->delete(); 106 | }); 107 | } 108 | 109 | /** 110 | * Create a schedule builder for a schedulable model. 111 | */ 112 | public function for(Model $schedulable): ScheduleBuilder 113 | { 114 | return (new ScheduleBuilder)->for($schedulable); 115 | } 116 | 117 | /** 118 | * Create a new schedule builder. 119 | */ 120 | public function schedule(): ScheduleBuilder 121 | { 122 | return new ScheduleBuilder; 123 | } 124 | 125 | /** 126 | * Find all schedules that conflict with the given schedule. 127 | */ 128 | public function findConflicts(Schedule $schedule): array 129 | { 130 | return $this->conflictService->findConflicts($schedule); 131 | } 132 | 133 | /** 134 | * Check if a schedule has conflicts. 135 | */ 136 | public function hasConflicts(Schedule $schedule): bool 137 | { 138 | return $this->conflictService->hasConflicts($schedule); 139 | } 140 | 141 | /** 142 | * Get available time slots for a schedulable on a given date. 143 | */ 144 | public function getAvailableSlots( 145 | Model $schedulable, 146 | string $date, 147 | string $startTime = '09:00', 148 | string $endTime = '17:00', 149 | int $slotDuration = 60 150 | ): array { 151 | if (method_exists($schedulable, 'getAvailableSlots')) { 152 | return $schedulable->getAvailableSlots($date, $startTime, $endTime, $slotDuration); 153 | } 154 | 155 | return []; 156 | } 157 | 158 | /** 159 | * Check if a schedulable is available at a specific time. 160 | */ 161 | public function isAvailable( 162 | Model $schedulable, 163 | string $date, 164 | string $startTime, 165 | string $endTime 166 | ): bool { 167 | if (method_exists($schedulable, 'isAvailableAt')) { 168 | return $schedulable->isAvailableAt($date, $startTime, $endTime); 169 | } 170 | 171 | return true; // Default to available if no schedule trait 172 | } 173 | 174 | /** 175 | * Get all schedules for a schedulable within a date range. 176 | */ 177 | public function getSchedulesForDateRange( 178 | Model $schedulable, 179 | string $startDate, 180 | string $endDate 181 | ): \Illuminate\Database\Eloquent\Collection { 182 | if (method_exists($schedulable, 'schedulesForDateRange')) { 183 | return $schedulable->schedulesForDateRange($startDate, $endDate)->get(); 184 | } 185 | 186 | return new \Illuminate\Database\Eloquent\Collection; 187 | } 188 | 189 | /** 190 | * Generate recurring schedule instances for a given period. 191 | */ 192 | public function generateRecurringInstances( 193 | Schedule $schedule, 194 | string $startDate, 195 | string $endDate 196 | ): array { 197 | if (! $schedule->is_recurring) { 198 | return []; 199 | } 200 | 201 | $instances = []; 202 | $current = \Carbon\Carbon::parse($startDate); 203 | $end = \Carbon\Carbon::parse($endDate); 204 | 205 | while ($current->lte($end)) { 206 | if ($this->shouldCreateInstance($schedule, $current)) { 207 | $instances[] = [ 208 | 'date' => $current->toDateString(), 209 | 'schedule' => $schedule, 210 | ]; 211 | } 212 | 213 | $current = $this->getNextRecurrence($schedule, $current); 214 | } 215 | 216 | return $instances; 217 | } 218 | 219 | /** 220 | * Check if a recurring instance should be created for the given date. 221 | */ 222 | private function shouldCreateInstance(Schedule $schedule, \Carbon\Carbon $date): bool 223 | { 224 | $frequency = $schedule->frequency; 225 | $config = $schedule->frequency_config ?? []; 226 | 227 | switch ($frequency) { 228 | case 'daily': 229 | return true; 230 | 231 | case 'weekly': 232 | $allowedDays = $config['days'] ?? []; 233 | 234 | return empty($allowedDays) || in_array(strtolower($date->format('l')), $allowedDays); 235 | 236 | case 'monthly': 237 | $dayOfMonth = $config['day_of_month'] ?? $date->day; 238 | 239 | return $date->day === $dayOfMonth; 240 | 241 | default: 242 | return false; 243 | } 244 | } 245 | 246 | /** 247 | * Get the next recurrence date. 248 | */ 249 | private function getNextRecurrence(Schedule $schedule, \Carbon\Carbon $current): \Carbon\Carbon 250 | { 251 | $frequency = $schedule->frequency; 252 | 253 | switch ($frequency) { 254 | case 'daily': 255 | return $current->addDay(); 256 | 257 | case 'weekly': 258 | return $current->addWeek(); 259 | 260 | case 'monthly': 261 | return $current->addMonth(); 262 | 263 | default: 264 | return $current->addDay(); 265 | } 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/ZapServiceProvider.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap; 4 | 5 | use Illuminate\Support\ServiceProvider; 6 | use Zap\Services\ConflictDetectionService; 7 | use Zap\Services\ScheduleService; 8 | use Zap\Services\ValidationService; 9 | 10 | class ZapServiceProvider extends ServiceProvider 11 | { 12 | /** 13 | * Register any application services. 14 | */ 15 | public function register(): void 16 | { 17 | $this->mergeConfigFrom(__DIR__.'/../config/zap.php', 'zap'); 18 | 19 | // Register core services 20 | $this->app->singleton(ScheduleService::class); 21 | $this->app->singleton(ConflictDetectionService::class); 22 | $this->app->singleton(ValidationService::class); 23 | 24 | // Register the facade 25 | $this->app->bind('zap', ScheduleService::class); 26 | } 27 | 28 | /** 29 | * Bootstrap any application services. 30 | */ 31 | public function boot(): void 32 | { 33 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 34 | 35 | if ($this->app->runningInConsole()) { 36 | $this->publishes([ 37 | __DIR__.'/../config/zap.php' => config_path('zap.php'), 38 | ], 'zap-config'); 39 | 40 | $this->publishes([ 41 | __DIR__.'/../database/migrations' => database_path('migrations'), 42 | ], 'zap-migrations'); 43 | } 44 | } 45 | 46 | /** 47 | * Get the services provided by the provider. 48 | */ 49 | public function provides(): array 50 | { 51 | return [ 52 | 'zap', 53 | ScheduleService::class, 54 | ConflictDetectionService::class, 55 | ValidationService::class, 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Feature/ConflictDetectionTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Zap\Enums\ScheduleTypes; 4 | use Zap\Exceptions\ScheduleConflictException; 5 | use Zap\Facades\Zap; 6 | use Zap\Models\Schedule; 7 | 8 | describe('Conflict Detection', function () { 9 | 10 | it('detects overlapping time periods on same date', function () { 11 | $user = createUser(); 12 | 13 | // Create first schedule 14 | Zap::for($user) 15 | ->from('2025-01-01') 16 | ->addPeriod('09:00', '11:00') 17 | ->save(); 18 | 19 | // This should conflict 20 | expect(function () use ($user) { 21 | Zap::for($user) 22 | ->from('2025-01-01') 23 | ->addPeriod('10:00', '12:00') // Overlaps with 09:00-11:00 24 | ->noOverlap() 25 | ->save(); 26 | })->toThrow(ScheduleConflictException::class); 27 | }); 28 | 29 | it('allows non-overlapping periods on same date', function () { 30 | $user = createUser(); 31 | 32 | // Create first schedule 33 | $schedule1 = Zap::for($user) 34 | ->from('2025-01-01') 35 | ->addPeriod('09:00', '10:00') 36 | ->save(); 37 | 38 | // This should not conflict 39 | $schedule2 = Zap::for($user) 40 | ->from('2025-01-01') 41 | ->addPeriod('11:00', '12:00') // No overlap with 09:00-10:00 42 | ->save(); 43 | 44 | expect($schedule1)->toBeInstanceOf(Schedule::class); 45 | expect($schedule2)->toBeInstanceOf(Schedule::class); 46 | }); 47 | 48 | it('allows overlapping periods on different dates', function () { 49 | $user = createUser(); 50 | 51 | // Create first schedule 52 | $schedule1 = Zap::for($user) 53 | ->from('2025-01-01') 54 | ->addPeriod('09:00', '11:00') 55 | ->save(); 56 | 57 | // This should not conflict (different date) 58 | $schedule2 = Zap::for($user) 59 | ->from('2025-01-02') 60 | ->addPeriod('09:00', '11:00') // Same time, different date 61 | ->save(); 62 | 63 | expect($schedule1)->toBeInstanceOf(Schedule::class); 64 | expect($schedule2)->toBeInstanceOf(Schedule::class); 65 | }); 66 | 67 | it('detects conflicts with buffer time', function () { 68 | config(['zap.conflict_detection.buffer_minutes' => 15]); 69 | 70 | $user = createUser(); 71 | 72 | // Create first schedule 73 | Zap::for($user) 74 | ->from('2025-01-01') 75 | ->addPeriod('09:00', '10:00') 76 | ->save(); 77 | 78 | // This should conflict due to buffer 79 | expect(function () use ($user) { 80 | Zap::for($user) 81 | ->from('2025-01-01') 82 | ->addPeriod('10:00', '11:00') // Would normally be OK, but buffer makes it conflict 83 | ->noOverlap() 84 | ->save(); 85 | })->toThrow(ScheduleConflictException::class); 86 | }); 87 | 88 | it('finds all conflicting schedules', function () { 89 | $user = createUser(); 90 | 91 | // Create multiple existing appointment schedules 92 | $schedule1 = Zap::for($user) 93 | ->named('Meeting 1') 94 | ->appointment() 95 | ->from('2025-01-01') 96 | ->addPeriod('09:00', '10:00') 97 | ->save(); 98 | 99 | $schedule2 = Zap::for($user) 100 | ->named('Meeting 2') 101 | ->appointment() 102 | ->from('2025-01-01') 103 | ->addPeriod('10:30', '11:30') 104 | ->save(); 105 | 106 | // Create a new appointment schedule that overlaps with both 107 | $newSchedule = new Schedule([ 108 | 'schedulable_type' => get_class($user), 109 | 'schedulable_id' => $user->getKey(), 110 | 'start_date' => '2025-01-01', 111 | 'name' => 'Conflicting Meeting', 112 | 'schedule_type' => ScheduleTypes::APPOINTMENT, 113 | ]); 114 | 115 | // Add periods that overlap with both existing schedules 116 | $newSchedule->setRelation('periods', collect([ 117 | new \Zap\Models\SchedulePeriod([ 118 | 'date' => '2025-01-01', 119 | 'start_time' => '09:30', // Overlaps with Meeting 1 (09:00-10:00) 120 | 'end_time' => '11:00', // Overlaps with Meeting 2 (10:30-11:30) 121 | ]), 122 | ])); 123 | 124 | $conflicts = Zap::findConflicts($newSchedule); 125 | expect($conflicts)->toHaveCount(2); 126 | }); 127 | 128 | it('ignores conflicts when detection is disabled', function () { 129 | config(['zap.conflict_detection.enabled' => false]); 130 | 131 | $user = createUser(); 132 | 133 | // Create first schedule 134 | Zap::for($user) 135 | ->from('2025-01-01') 136 | ->addPeriod('09:00', '11:00') 137 | ->save(); 138 | 139 | // This should succeed when conflict detection is disabled 140 | $schedule2 = Zap::for($user) 141 | ->from('2025-01-01') 142 | ->addPeriod('10:00', '12:00') // Would normally conflict 143 | ->save(); 144 | 145 | expect($schedule2)->toBeInstanceOf(Schedule::class); 146 | }); 147 | 148 | it('handles complex recurring schedule conflicts', function () { 149 | $user = createUser(); 150 | 151 | // Create recurring schedule 152 | Zap::for($user) 153 | ->named('Weekly Meeting') 154 | ->from('2025-01-01') 155 | ->to('2025-12-31') 156 | ->addPeriod('09:00', '10:00') 157 | ->weekly(['monday']) 158 | ->save(); 159 | 160 | // Try to create conflicting one-time event 161 | expect(function () use ($user) { 162 | Zap::for($user) 163 | ->from('2025-01-06') // This is a Monday (Jan 6, 2025) 164 | ->addPeriod('09:30', '10:30') 165 | ->noOverlap() 166 | ->save(); 167 | })->toThrow(ScheduleConflictException::class); 168 | }); 169 | 170 | it('provides detailed conflict information in exceptions', function () { 171 | $user = createUser(); 172 | 173 | // Create first schedule 174 | $conflictingSchedule = Zap::for($user) 175 | ->named('Existing Meeting') 176 | ->from('2025-01-01') 177 | ->addPeriod('09:00', '10:00') 178 | ->save(); 179 | 180 | // Try to create conflicting schedule 181 | try { 182 | Zap::for($user) 183 | ->from('2025-01-01') 184 | ->addPeriod('09:30', '10:30') 185 | ->noOverlap() 186 | ->save(); 187 | } catch (ScheduleConflictException $e) { 188 | expect($e->getConflictingSchedules())->toHaveCount(1); 189 | expect($e->getConflictingSchedules()[0]->name)->toBe('Existing Meeting'); 190 | } 191 | }); 192 | 193 | }); 194 | 195 | describe('Availability Checking', function () { 196 | 197 | it('correctly identifies available time slots', function () { 198 | $user = createUser(); 199 | 200 | // Block morning 201 | Zap::for($user) 202 | ->from('2025-01-01') 203 | ->addPeriod('09:00', '12:00') 204 | ->save(); 205 | 206 | // Check various time slots 207 | expect($user->isAvailableAt('2025-01-01', '08:00', '09:00'))->toBeTrue(); // Before 208 | expect($user->isAvailableAt('2025-01-01', '09:00', '10:00'))->toBeFalse(); // During 209 | expect($user->isAvailableAt('2025-01-01', '10:00', '11:00'))->toBeFalse(); // During 210 | expect($user->isAvailableAt('2025-01-01', '12:00', '13:00'))->toBeTrue(); // After 211 | }); 212 | 213 | it('generates accurate available slots', function () { 214 | $user = createUser(); 215 | 216 | // Block 10:00-11:00 217 | Zap::for($user) 218 | ->from('2025-01-01') 219 | ->addPeriod('10:00', '11:00') 220 | ->save(); 221 | 222 | $slots = $user->getAvailableSlots('2025-01-01', '09:00', '13:00', 60); 223 | 224 | expect($slots)->toHaveCount(4); 225 | expect($slots[0]['is_available'])->toBeTrue(); // 09:00-10:00 226 | expect($slots[1]['is_available'])->toBeFalse(); // 10:00-11:00 (blocked) 227 | expect($slots[2]['is_available'])->toBeTrue(); // 11:00-12:00 228 | expect($slots[3]['is_available'])->toBeTrue(); // 12:00-13:00 229 | }); 230 | 231 | it('finds next available slot across multiple days', function () { 232 | $user = createUser(); 233 | 234 | // Block entire first day 235 | Zap::for($user) 236 | ->from('2025-01-01') 237 | ->addPeriod('09:00', '17:00') 238 | ->save(); 239 | 240 | $nextSlot = $user->getNextAvailableSlot('2025-01-01', 60, '09:00', '17:00'); 241 | 242 | expect($nextSlot)->toBeArray(); 243 | expect($nextSlot['date'])->toBe('2025-01-02'); // Should find slot on next day 244 | expect($nextSlot['start_time'])->toBe('09:00'); 245 | }); 246 | 247 | }); 248 | -------------------------------------------------------------------------------- /tests/Feature/ImprovedValidationErrorsTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Zap\Exceptions\InvalidScheduleException; 4 | use Zap\Facades\Zap; 5 | 6 | describe('Improved Validation Error Messages', function () { 7 | 8 | beforeEach(function () { 9 | config([ 10 | 'zap.validation.require_future_dates' => true, 11 | 'zap.validation.min_period_duration' => 15, 12 | 'zap.validation.max_period_duration' => 480, 13 | 'zap.validation.allow_overlapping_periods' => false, 14 | ]); 15 | }); 16 | 17 | it('provides clear error message for missing start date', function () { 18 | $user = createUser(); 19 | 20 | try { 21 | Zap::for($user) 22 | ->named('Test Schedule') 23 | ->addPeriod('09:00', '10:00') 24 | ->save(); 25 | } catch (\InvalidArgumentException $e) { 26 | // ScheduleBuilder throws InvalidArgumentException before validation 27 | expect($e->getMessage())->toBe('Start date must be set using from() method'); 28 | } 29 | }); 30 | 31 | it('provides clear error message for invalid time format', function () { 32 | $user = createUser(); 33 | 34 | try { 35 | Zap::for($user) 36 | ->from(now()->addDay()->toDateString()) 37 | ->addPeriod('9am', '10am') // Invalid format 38 | ->save(); 39 | } catch (InvalidScheduleException $e) { 40 | expect($e->getMessage())->toContain('Invalid start time format'); 41 | expect($e->getMessage())->toContain('9am'); 42 | expect($e->getMessage())->toContain('Please use HH:MM format'); 43 | 44 | $errors = $e->getErrors(); 45 | expect($errors)->toHaveKey('periods.0.start_time'); 46 | expect($errors['periods.0.start_time'])->toContain('9am'); 47 | } 48 | }); 49 | 50 | it('provides clear error message for end time before start time', function () { 51 | $user = createUser(); 52 | 53 | try { 54 | Zap::for($user) 55 | ->from(now()->addDay()->toDateString()) 56 | ->addPeriod('10:00', '09:00') // End before start 57 | ->save(); 58 | } catch (InvalidScheduleException $e) { 59 | expect($e->getMessage())->toContain('End time (09:00) must be after start time (10:00)'); 60 | 61 | $errors = $e->getErrors(); 62 | expect($errors)->toHaveKey('periods.0.end_time'); 63 | expect($errors['periods.0.end_time'])->toBe('End time (09:00) must be after start time (10:00)'); 64 | } 65 | }); 66 | 67 | it('provides clear error message for period too short', function () { 68 | $user = createUser(); 69 | 70 | try { 71 | Zap::for($user) 72 | ->from(now()->addDay()->toDateString()) 73 | ->addPeriod('09:00', '09:10') // Only 10 minutes, minimum is 15 74 | ->save(); 75 | } catch (InvalidScheduleException $e) { 76 | expect($e->getMessage())->toContain('Period is too short (10 minutes)'); 77 | expect($e->getMessage())->toContain('Minimum duration is 15 minutes'); 78 | 79 | $errors = $e->getErrors(); 80 | expect($errors)->toHaveKey('periods.0.duration'); 81 | } 82 | }); 83 | 84 | it('provides clear error message for period too long', function () { 85 | $user = createUser(); 86 | 87 | try { 88 | Zap::for($user) 89 | ->from(now()->addDay()->toDateString()) 90 | ->addPeriod('09:00', '17:30') // 8.5 hours = 510 minutes, maximum is 8 hours (480 minutes) 91 | ->maxDuration(480) // Set max duration rule 92 | ->save(); 93 | } catch (InvalidScheduleException $e) { 94 | expect($e->getMessage())->toContain('Period 09:00-17:30 is too long (8.5 hours)'); 95 | expect($e->getMessage())->toContain('Maximum allowed is 8 hours'); 96 | 97 | $errors = $e->getErrors(); 98 | expect($errors)->toHaveKey('periods.0.max_duration'); 99 | } 100 | }); 101 | 102 | it('provides clear error message for overlapping periods within same schedule', function () { 103 | $user = createUser(); 104 | 105 | try { 106 | Zap::for($user) 107 | ->from(now()->addDay()->toDateString()) 108 | ->addPeriod('09:00', '11:00') 109 | ->addPeriod('10:00', '12:00') // Overlaps with first period 110 | ->save(); 111 | } catch (InvalidScheduleException $e) { 112 | expect($e->getMessage())->toContain('Period 0 (09:00-11:00) overlaps with period 1 (10:00-12:00)'); 113 | 114 | $errors = $e->getErrors(); 115 | expect($errors)->toHaveKey('periods.0.overlap'); 116 | } 117 | }); 118 | 119 | it('provides clear error message for working hours violation', function () { 120 | $user = createUser(); 121 | 122 | try { 123 | Zap::for($user) 124 | ->from(now()->addDay()->toDateString()) 125 | ->addPeriod('08:00', '09:00') // Before working hours 126 | ->workingHoursOnly('09:00', '17:00') 127 | ->save(); 128 | } catch (InvalidScheduleException $e) { 129 | expect($e->getMessage())->toContain('Period 08:00-09:00 is outside working hours (09:00-17:00)'); 130 | 131 | $errors = $e->getErrors(); 132 | expect($errors)->toHaveKey('periods.0.working_hours'); 133 | } 134 | }); 135 | 136 | it('provides clear error message for weekend violation', function () { 137 | config(['zap.default_rules.no_weekends.enabled' => true]); 138 | 139 | $user = createUser(); 140 | 141 | // Find the next Saturday 142 | $nextSaturday = now()->next(\Carbon\Carbon::SATURDAY); 143 | 144 | try { 145 | Zap::for($user) 146 | ->from($nextSaturday->toDateString()) 147 | ->addPeriod('09:00', '10:00') 148 | ->noWeekends() 149 | ->save(); 150 | } catch (InvalidScheduleException $e) { 151 | expect($e->getMessage())->toContain('Schedule cannot start on Saturday'); 152 | expect($e->getMessage())->toContain('Weekend schedules are not allowed'); 153 | 154 | $errors = $e->getErrors(); 155 | expect($errors)->toHaveKey('start_date'); 156 | } 157 | }); 158 | 159 | it('provides clear error message for past date', function () { 160 | $user = createUser(); 161 | 162 | try { 163 | Zap::for($user) 164 | ->from(now()->subDay()->toDateString()) // Yesterday (past date) 165 | ->addPeriod('09:00', '10:00') 166 | ->save(); 167 | } catch (InvalidScheduleException $e) { 168 | expect($e->getMessage())->toContain('The schedule cannot be created in the past'); 169 | expect($e->getMessage())->toContain('Please choose a future date'); 170 | 171 | $errors = $e->getErrors(); 172 | expect($errors)->toHaveKey('start_date'); 173 | } 174 | }); 175 | 176 | it('provides clear error message for schedule conflicts', function () { 177 | $user = createUser(); 178 | $futureDate = now()->addWeek()->toDateString(); 179 | 180 | // Create existing schedule 181 | Zap::for($user) 182 | ->named('Existing Meeting') 183 | ->from($futureDate) 184 | ->addPeriod('09:00', '10:00') 185 | ->save(); 186 | 187 | // Try to create conflicting schedule 188 | try { 189 | Zap::for($user) 190 | ->named('Conflicting Meeting') 191 | ->from($futureDate) 192 | ->addPeriod('09:30', '10:30') // Overlaps with existing 193 | ->noOverlap() 194 | ->save(); 195 | } catch (\Zap\Exceptions\ScheduleConflictException $e) { 196 | expect($e->getMessage())->toContain('Schedule conflict detected!'); 197 | expect($e->getMessage())->toContain('New schedule'); // The temp schedule doesn't have the name set 198 | expect($e->getMessage())->toContain('Existing Meeting'); 199 | expect($e->getConflictingSchedules())->toHaveCount(1); 200 | expect($e->getConflictingSchedules()[0]->name)->toBe('Existing Meeting'); 201 | } 202 | }); 203 | 204 | it('provides detailed error summary with multiple errors', function () { 205 | $user = createUser(); 206 | 207 | try { 208 | Zap::for($user) 209 | ->from(now()->addDay()->toDateString()) 210 | ->addPeriod('24:00', '09:00') // Invalid time format (error 1) - 24:00 is invalid 211 | ->addPeriod('', '10:00') // Missing start time (error 2) 212 | ->save(); 213 | } catch (InvalidScheduleException $e) { 214 | $message = $e->getMessage(); 215 | 216 | // Should mention multiple errors 217 | expect($message)->toContain('Schedule validation failed with'); 218 | expect($message)->toContain('errors:'); 219 | 220 | // Should list all errors with bullet points 221 | expect($message)->toContain('•'); 222 | expect($message)->toContain('Invalid start time format'); 223 | expect($message)->toContain('A start time is required'); 224 | 225 | // Should have all errors in the errors array 226 | $errors = $e->getErrors(); 227 | expect(count($errors))->toBeGreaterThanOrEqual(2); 228 | } 229 | }); 230 | 231 | }); 232 | -------------------------------------------------------------------------------- /tests/Feature/OriginalIssuesFixedTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Zap\Exceptions\ScheduleConflictException; 4 | use Zap\Facades\Zap; 5 | use Zap\Models\Schedule; 6 | 7 | describe('Original Issues Fixed', function () { 8 | 9 | beforeEach(function () { 10 | config([ 11 | 'zap.conflict_detection.enabled' => true, 12 | 'zap.conflict_detection.buffer_minutes' => 0, 13 | ]); 14 | }); 15 | 16 | it('FIXED: User working Mon/Wed/Fri conflicts with daily schedule on those days', function () { 17 | $user = createUser(); 18 | 19 | // User works Monday, Wednesday, Friday from 8:00-12:00 and 14:00-18:00 20 | Zap::for($user) 21 | ->named('Work Schedule') 22 | ->from('2024-01-01') 23 | ->to('2024-12-31') 24 | ->addPeriod('08:00', '12:00') 25 | ->addPeriod('14:00', '18:00') 26 | ->weekly(['monday', 'wednesday', 'friday']) 27 | ->save(); 28 | 29 | // Second user tries to schedule daily from 14:00-18:00 with no overlap 30 | // This SHOULD trigger an exception because it overlaps on Mon/Wed/Fri 31 | // BEFORE FIX: This would NOT trigger an exception (false negative) 32 | // AFTER FIX: This correctly triggers an exception 33 | expect(function () use ($user) { 34 | Zap::for($user) 35 | ->named('Daily Afternoon') 36 | ->from('2024-01-01') 37 | ->to('2024-12-31') 38 | ->addPeriod('14:00', '18:00') 39 | ->daily() 40 | ->noOverlap() 41 | ->save(); 42 | })->toThrow(ScheduleConflictException::class); 43 | }); 44 | 45 | it('FIXED: User working Mon/Wed/Fri does NOT conflict with Sunday schedule', function () { 46 | $user = createUser(); 47 | 48 | // User works Monday, Wednesday, Friday from 8:00-12:00 and 14:00-18:00 49 | Zap::for($user) 50 | ->named('Work Schedule') 51 | ->from('2024-01-01') 52 | ->to('2024-12-31') 53 | ->addPeriod('08:00', '12:00') 54 | ->addPeriod('14:00', '18:00') 55 | ->weekly(['monday', 'wednesday', 'friday']) 56 | ->save(); 57 | 58 | // Second user tries to schedule on Sunday from 14:00-18:00 with no overlap 59 | // This SHOULD NOT trigger an exception because user doesn't work on Sunday 60 | // BEFORE FIX: This would trigger an exception (false positive) 61 | // AFTER FIX: This correctly does NOT trigger an exception 62 | $schedule = Zap::for($user) 63 | ->named('Sunday Meeting') 64 | ->from('2024-01-07') // Sunday 65 | ->addPeriod('14:00', '18:00') 66 | ->noOverlap() 67 | ->save(); 68 | 69 | expect($schedule)->toBeInstanceOf(Schedule::class); 70 | expect($schedule->name)->toBe('Sunday Meeting'); 71 | }); 72 | 73 | it('demonstrates the fix works across multiple weeks', function () { 74 | $user = createUser(); 75 | 76 | // User works Monday, Wednesday, Friday from 9:00-17:00 77 | Zap::for($user) 78 | ->named('Work Schedule') 79 | ->from('2024-01-01') 80 | ->to('2024-12-31') 81 | ->addPeriod('09:00', '17:00') 82 | ->weekly(['monday', 'wednesday', 'friday']) 83 | ->save(); 84 | 85 | // Should conflict on Monday in week 3 86 | expect(function () use ($user) { 87 | Zap::for($user) 88 | ->from('2024-01-15') // Monday, week 3 89 | ->addPeriod('10:00', '11:00') 90 | ->noOverlap() 91 | ->save(); 92 | })->toThrow(ScheduleConflictException::class); 93 | 94 | // Should NOT conflict on Tuesday in week 3 95 | $tuesdaySchedule = Zap::for($user) 96 | ->from('2024-01-16') // Tuesday, week 3 97 | ->addPeriod('10:00', '11:00') 98 | ->noOverlap() 99 | ->save(); 100 | 101 | expect($tuesdaySchedule)->toBeInstanceOf(Schedule::class); 102 | 103 | // Should conflict on Wednesday in week 3 104 | expect(function () use ($user) { 105 | Zap::for($user) 106 | ->from('2024-01-17') // Wednesday, week 3 107 | ->addPeriod('10:00', '11:00') 108 | ->noOverlap() 109 | ->save(); 110 | })->toThrow(ScheduleConflictException::class); 111 | 112 | // Should NOT conflict on Thursday in week 3 113 | $thursdaySchedule = Zap::for($user) 114 | ->from('2024-01-18') // Thursday, week 3 115 | ->addPeriod('10:00', '11:00') 116 | ->noOverlap() 117 | ->save(); 118 | 119 | expect($thursdaySchedule)->toBeInstanceOf(Schedule::class); 120 | 121 | // Should conflict on Friday in week 3 122 | expect(function () use ($user) { 123 | Zap::for($user) 124 | ->from('2024-01-19') // Friday, week 3 125 | ->addPeriod('10:00', '11:00') 126 | ->noOverlap() 127 | ->save(); 128 | })->toThrow(ScheduleConflictException::class); 129 | }); 130 | 131 | }); 132 | -------------------------------------------------------------------------------- /tests/Feature/OverlapRulesEdgeCasesTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Zap\Exceptions\ScheduleConflictException; 4 | use Zap\Facades\Zap; 5 | use Zap\Models\Schedule; 6 | 7 | describe('Overlap Rules Edge Cases', function () { 8 | 9 | beforeEach(function () { 10 | // Reset config to ensure consistent test environment 11 | config([ 12 | 'zap.conflict_detection.enabled' => true, 13 | 'zap.conflict_detection.buffer_minutes' => 0, 14 | ]); 15 | }); 16 | 17 | describe('Weekly Recurring Schedule Conflicts', function () { 18 | 19 | it('should detect overlap when second user schedules daily on recurring schedule days', function () { 20 | $userA = createUser(); 21 | $userB = createUser(); 22 | 23 | // User A works Monday, Wednesday, Friday from 8:00-12:00 and 14:00-18:00 24 | Zap::for($userA) 25 | ->named('User A Work Schedule') 26 | ->from('2024-01-01') 27 | ->to('2024-12-31') 28 | ->addPeriod('08:00', '12:00') 29 | ->addPeriod('14:00', '18:00') 30 | ->weekly(['monday', 'wednesday', 'friday']) 31 | ->save(); 32 | 33 | // User B tries to schedule daily from 14:00-18:00 with no overlap 34 | // This SHOULD throw an exception because it overlaps on Mon/Wed/Fri 35 | expect(function () use ($userA) { 36 | Zap::for($userA) // Same user, should conflict 37 | ->named('User B Daily Schedule') 38 | ->from('2024-01-01') 39 | ->to('2024-12-31') 40 | ->addPeriod('14:00', '18:00') 41 | ->daily() 42 | ->noOverlap() 43 | ->save(); 44 | })->toThrow(ScheduleConflictException::class); 45 | }); 46 | 47 | it('should NOT detect overlap when second user schedules on non-recurring days only', function () { 48 | $userA = createUser(); 49 | 50 | // User A works Monday, Wednesday, Friday from 8:00-12:00 and 14:00-18:00 51 | Zap::for($userA) 52 | ->named('User A Work Schedule') 53 | ->from('2024-01-01') 54 | ->to('2024-12-31') 55 | ->addPeriod('08:00', '12:00') 56 | ->addPeriod('14:00', '18:00') 57 | ->weekly(['monday', 'wednesday', 'friday']) 58 | ->save(); 59 | 60 | // User A tries to schedule on Sunday (non-recurring day) with no overlap 61 | // This SHOULD NOT throw an exception because User A doesn't work on Sunday 62 | $schedule = Zap::for($userA) // Same user 63 | ->named('Sunday Schedule') 64 | ->from('2024-01-07') // Sunday, January 7, 2024 65 | ->addPeriod('14:00', '18:00') 66 | ->noOverlap() 67 | ->save(); 68 | 69 | expect($schedule)->toBeInstanceOf(Schedule::class); 70 | expect($schedule->name)->toBe('Sunday Schedule'); 71 | }); 72 | 73 | it('should handle multiple period conflicts correctly', function () { 74 | $user = createUser(); 75 | 76 | // User has recurring schedule with multiple periods on specific days 77 | Zap::for($user) 78 | ->named('Work Schedule') 79 | ->from('2024-01-01') 80 | ->to('2024-12-31') 81 | ->addPeriod('08:00', '12:00') // Morning 82 | ->addPeriod('14:00', '18:00') // Afternoon 83 | ->weekly(['monday', 'wednesday', 'friday']) 84 | ->save(); 85 | 86 | // Try to schedule overlapping with morning period on a working day 87 | expect(function () use ($user) { 88 | Zap::for($user) 89 | ->from('2024-01-01') // Monday 90 | ->addPeriod('10:00', '11:00') // Overlaps with 08:00-12:00 91 | ->noOverlap() 92 | ->save(); 93 | })->toThrow(ScheduleConflictException::class); 94 | 95 | // Try to schedule overlapping with afternoon period on a working day 96 | expect(function () use ($user) { 97 | Zap::for($user) 98 | ->from('2024-01-03') // Wednesday 99 | ->addPeriod('15:00', '16:00') // Overlaps with 14:00-18:00 100 | ->noOverlap() 101 | ->save(); 102 | })->toThrow(ScheduleConflictException::class); 103 | 104 | // Schedule during lunch break on working day should succeed 105 | $lunchSchedule = Zap::for($user) 106 | ->from('2024-01-05') // Friday 107 | ->addPeriod('12:00', '14:00') // Lunch break 108 | ->noOverlap() 109 | ->save(); 110 | 111 | expect($lunchSchedule)->toBeInstanceOf(Schedule::class); 112 | }); 113 | 114 | it('should correctly identify conflicts across different weeks', function () { 115 | $user = createUser(); 116 | 117 | // Recurring schedule for several weeks 118 | Zap::for($user) 119 | ->named('Weekly Meeting') 120 | ->from('2024-01-01') 121 | ->to('2024-02-29') 122 | ->addPeriod('10:00', '11:00') 123 | ->weekly(['tuesday']) 124 | ->save(); 125 | 126 | // Try to schedule on a Tuesday 3 weeks later - should conflict 127 | expect(function () use ($user) { 128 | Zap::for($user) 129 | ->from('2024-01-23') // Tuesday, 3 weeks later 130 | ->addPeriod('10:30', '11:30') // Overlaps with 10:00-11:00 131 | ->noOverlap() 132 | ->save(); 133 | })->toThrow(ScheduleConflictException::class); 134 | 135 | // Schedule on Wednesday same week should succeed 136 | $wednesdaySchedule = Zap::for($user) 137 | ->from('2024-01-24') // Wednesday 138 | ->addPeriod('10:00', '11:00') // Same time, different day 139 | ->noOverlap() 140 | ->save(); 141 | 142 | expect($wednesdaySchedule)->toBeInstanceOf(Schedule::class); 143 | }); 144 | 145 | }); 146 | 147 | describe('Daily Recurring Schedule Conflicts', function () { 148 | 149 | it('should detect conflicts with daily recurring schedules', function () { 150 | $user = createUser(); 151 | 152 | // Daily work schedule 153 | Zap::for($user) 154 | ->named('Daily Work') 155 | ->from('2024-01-01') 156 | ->to('2024-12-31') 157 | ->addPeriod('09:00', '17:00') 158 | ->daily() 159 | ->save(); 160 | 161 | // Try to schedule during work hours - should conflict 162 | expect(function () use ($user) { 163 | Zap::for($user) 164 | ->from('2024-01-15') 165 | ->addPeriod('10:00', '11:00') 166 | ->noOverlap() 167 | ->save(); 168 | })->toThrow(ScheduleConflictException::class); 169 | }); 170 | 171 | it('should handle weekday-only recurring schedules', function () { 172 | $user = createUser(); 173 | 174 | // Weekday work schedule (Monday through Friday) 175 | Zap::for($user) 176 | ->named('Weekday Work') 177 | ->from('2024-01-01') 178 | ->to('2024-12-31') 179 | ->addPeriod('09:00', '17:00') 180 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 181 | ->save(); 182 | 183 | // Try to schedule on a Saturday - should succeed 184 | $weekendSchedule = Zap::for($user) 185 | ->from('2024-01-06') // Saturday 186 | ->addPeriod('10:00', '11:00') 187 | ->noOverlap() 188 | ->save(); 189 | 190 | expect($weekendSchedule)->toBeInstanceOf(Schedule::class); 191 | 192 | // Try to schedule on a weekday - should conflict 193 | expect(function () use ($user) { 194 | Zap::for($user) 195 | ->from('2024-01-08') // Monday 196 | ->addPeriod('10:00', '11:00') 197 | ->noOverlap() 198 | ->save(); 199 | })->toThrow(ScheduleConflictException::class); 200 | }); 201 | 202 | }); 203 | 204 | describe('Monthly Recurring Schedule Conflicts', function () { 205 | 206 | it('should detect conflicts with monthly recurring schedules', function () { 207 | $user = createUser(); 208 | 209 | // Monthly meeting on the 15th 210 | Zap::for($user) 211 | ->named('Monthly Meeting') 212 | ->from('2024-01-15') 213 | ->to('2024-12-31') 214 | ->addPeriod('14:00', '16:00') 215 | ->monthly(['day_of_month' => 15]) 216 | ->save(); 217 | 218 | // Try to schedule on March 15th - should conflict 219 | expect(function () use ($user) { 220 | Zap::for($user) 221 | ->from('2024-03-15') 222 | ->addPeriod('15:00', '17:00') 223 | ->noOverlap() 224 | ->save(); 225 | })->toThrow(ScheduleConflictException::class); 226 | 227 | // Schedule on March 16th should succeed 228 | $nextDaySchedule = Zap::for($user) 229 | ->from('2024-03-16') 230 | ->addPeriod('14:00', '16:00') 231 | ->noOverlap() 232 | ->save(); 233 | 234 | expect($nextDaySchedule)->toBeInstanceOf(Schedule::class); 235 | }); 236 | 237 | }); 238 | 239 | describe('Complex Overlapping Scenarios', function () { 240 | 241 | it('should handle overlapping schedules with different frequencies', function () { 242 | $user = createUser(); 243 | 244 | // Weekly recurring schedule 245 | Zap::for($user) 246 | ->named('Weekly Team Meeting') 247 | ->from('2024-01-01') 248 | ->to('2024-12-31') 249 | ->addPeriod('10:00', '11:00') 250 | ->weekly(['monday']) 251 | ->save(); 252 | 253 | // Daily recurring schedule that should conflict on Mondays 254 | expect(function () use ($user) { 255 | Zap::for($user) 256 | ->named('Daily Standup') 257 | ->from('2024-01-01') 258 | ->to('2024-12-31') 259 | ->addPeriod('10:30', '11:30') 260 | ->daily() 261 | ->noOverlap() 262 | ->save(); 263 | })->toThrow(ScheduleConflictException::class); 264 | }); 265 | 266 | it('should correctly handle end date boundaries in recurring schedules', function () { 267 | $user = createUser(); 268 | 269 | // Short-term recurring schedule 270 | Zap::for($user) 271 | ->named('Short Term Project') 272 | ->from('2024-01-01') 273 | ->to('2024-01-31') // Only January 274 | ->addPeriod('09:00', '10:00') 275 | ->weekly(['monday']) 276 | ->save(); 277 | 278 | // Try to schedule on a Monday in February - should succeed (no conflict) 279 | $februarySchedule = Zap::for($user) 280 | ->from('2024-02-05') // Monday in February 281 | ->addPeriod('09:00', '10:00') 282 | ->noOverlap() 283 | ->save(); 284 | 285 | expect($februarySchedule)->toBeInstanceOf(Schedule::class); 286 | 287 | // Try to schedule on a Monday in January - should conflict 288 | expect(function () use ($user) { 289 | Zap::for($user) 290 | ->from('2024-01-08') // Monday in January 291 | ->addPeriod('09:30', '10:30') 292 | ->noOverlap() 293 | ->save(); 294 | })->toThrow(ScheduleConflictException::class); 295 | }); 296 | 297 | }); 298 | 299 | describe('Edge Cases with Time Boundaries', function () { 300 | 301 | it('should handle adjacent time periods correctly', function () { 302 | $user = createUser(); 303 | 304 | // Morning block 305 | Zap::for($user) 306 | ->from('2024-01-01') 307 | ->addPeriod('09:00', '12:00') 308 | ->save(); 309 | 310 | // Adjacent afternoon block should succeed 311 | $afternoonSchedule = Zap::for($user) 312 | ->from('2024-01-01') 313 | ->addPeriod('12:00', '15:00') // Starts exactly when morning ends 314 | ->noOverlap() 315 | ->save(); 316 | 317 | expect($afternoonSchedule)->toBeInstanceOf(Schedule::class); 318 | 319 | // Overlapping by one minute should fail 320 | expect(function () use ($user) { 321 | Zap::for($user) 322 | ->from('2024-01-01') 323 | ->addPeriod('11:59', '13:00') // Overlaps by 1 minute 324 | ->noOverlap() 325 | ->save(); 326 | })->toThrow(ScheduleConflictException::class); 327 | }); 328 | 329 | it('should handle midnight boundary correctly', function () { 330 | $user = createUser(); 331 | 332 | // Late evening schedule 333 | Zap::for($user) 334 | ->from('2024-01-01') 335 | ->addPeriod('23:00', '23:59') 336 | ->save(); 337 | 338 | // Early morning next day should succeed 339 | $morningSchedule = Zap::for($user) 340 | ->from('2024-01-02') 341 | ->addPeriod('00:00', '01:00') 342 | ->noOverlap() 343 | ->save(); 344 | 345 | expect($morningSchedule)->toBeInstanceOf(Schedule::class); 346 | }); 347 | 348 | }); 349 | 350 | }); 351 | -------------------------------------------------------------------------------- /tests/Feature/RecurringScheduleAvailabilityTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Carbon\Carbon; 4 | use Zap\Facades\Zap; 5 | 6 | describe('Recurring Schedule Availability', function () { 7 | 8 | beforeEach(function () { 9 | // Set a known date for testing 10 | Carbon::setTestNow('2025-03-14 08:00:00'); // Friday 11 | }); 12 | 13 | afterEach(function () { 14 | Carbon::setTestNow(); // Reset 15 | }); 16 | 17 | it('user with recurring schedule starting tomorrow afternoon should not be available during scheduled time', function () { 18 | $user = createUser(); 19 | 20 | // Create a recurring schedule that starts tomorrow (Saturday) afternoon 21 | $schedule = Zap::for($user) 22 | ->named('Weekend Work') 23 | ->from('2025-03-15') // Tomorrow (Saturday) 24 | ->to('2025-12-31') 25 | ->addPeriod('14:00', '18:00') // Afternoon only 26 | ->weekly(['saturday', 'sunday']) 27 | ->save(); 28 | 29 | // User should be available tomorrow morning (before the schedule starts) 30 | expect($user->isAvailableAt('2025-03-15', '09:00', '12:00'))->toBeTrue( 31 | 'User should be available in the morning since schedule only covers afternoon' 32 | ); 33 | 34 | // User should NOT be available during the scheduled time 35 | expect($user->isAvailableAt('2025-03-15', '14:00', '16:00'))->toBeFalse( 36 | 'User should NOT be available during scheduled work hours' 37 | ); 38 | 39 | // User should be available after the scheduled time 40 | expect($user->isAvailableAt('2025-03-15', '19:00', '20:00'))->toBeTrue( 41 | 'User should be available after scheduled work hours' 42 | ); 43 | 44 | // User should also be blocked on Sunday (next day in the weekly schedule) 45 | expect($user->isAvailableAt('2025-03-16', '14:00', '16:00'))->toBeFalse( 46 | 'User should NOT be available on Sunday during scheduled hours' 47 | ); 48 | 49 | // User should be available on Monday (not in the weekly schedule) 50 | expect($user->isAvailableAt('2025-03-17', '14:00', '16:00'))->toBeTrue( 51 | 'User should be available on Monday (not in weekend schedule)' 52 | ); 53 | }); 54 | 55 | it('demonstrates the exact scenario from the user report', function () { 56 | $user = createUser(); 57 | 58 | // Create a user with a recurring schedule which starts tomorrow in the afternoon 59 | $schedule = Zap::for($user) 60 | ->named('Afternoon Meetings') 61 | ->from('2025-03-15') // Tomorrow 62 | ->addPeriod('14:00', '17:00') 63 | ->weekly(['saturday']) 64 | ->save(); 65 | 66 | // isAvailableAt should return false for the scheduled time, not true 67 | expect($user->isAvailableAt('2025-03-15', '14:00', '16:00'))->toBeFalse( 68 | 'User should NOT be available during recurring schedule time' 69 | ); 70 | 71 | // But should be available before the schedule starts 72 | expect($user->isAvailableAt('2025-03-15', '10:00', '12:00'))->toBeTrue( 73 | 'User should be available before the recurring schedule starts' 74 | ); 75 | }); 76 | 77 | it('handles complex weekly recurring schedules correctly', function () { 78 | $user = createUser(); 79 | 80 | // Monday, Wednesday, Friday schedule 81 | $schedule = Zap::for($user) 82 | ->named('MWF Classes') 83 | ->from('2025-03-17') // Next Monday 84 | ->addPeriod('10:00', '12:00') 85 | ->weekly(['monday', 'wednesday', 'friday']) 86 | ->save(); 87 | 88 | // Test each day of the week 89 | expect($user->isAvailableAt('2025-03-17', '10:00', '11:00'))->toBeFalse('Monday should be blocked'); 90 | expect($user->isAvailableAt('2025-03-18', '10:00', '11:00'))->toBeTrue('Tuesday should be available'); 91 | expect($user->isAvailableAt('2025-03-19', '10:00', '11:00'))->toBeFalse('Wednesday should be blocked'); 92 | expect($user->isAvailableAt('2025-03-20', '10:00', '11:00'))->toBeTrue('Thursday should be available'); 93 | expect($user->isAvailableAt('2025-03-21', '10:00', '11:00'))->toBeFalse('Friday should be blocked'); 94 | expect($user->isAvailableAt('2025-03-22', '10:00', '11:00'))->toBeTrue('Saturday should be available'); 95 | expect($user->isAvailableAt('2025-03-23', '10:00', '11:00'))->toBeTrue('Sunday should be available'); 96 | }); 97 | 98 | it('handles daily recurring schedules correctly', function () { 99 | $user = createUser(); 100 | 101 | $schedule = Zap::for($user) 102 | ->named('Daily Standup') 103 | ->from('2025-03-15') // Tomorrow 104 | ->addPeriod('09:00', '09:30') 105 | ->daily() 106 | ->save(); 107 | 108 | // Should be blocked every day during the standup time 109 | expect($user->isAvailableAt('2025-03-15', '09:00', '09:30'))->toBeFalse('Saturday should be blocked'); 110 | expect($user->isAvailableAt('2025-03-16', '09:00', '09:30'))->toBeFalse('Sunday should be blocked'); 111 | expect($user->isAvailableAt('2025-03-17', '09:00', '09:30'))->toBeFalse('Monday should be blocked'); 112 | 113 | // But available at other times 114 | expect($user->isAvailableAt('2025-03-15', '10:00', '11:00'))->toBeTrue('Should be available after standup'); 115 | }); 116 | 117 | it('handles monthly recurring schedules correctly', function () { 118 | $user = createUser(); 119 | 120 | // First day of every month 121 | $schedule = Zap::for($user) 122 | ->named('Monthly Review') 123 | ->from('2025-04-01') // First day of April 124 | ->addPeriod('14:00', '16:00') 125 | ->monthly(['day_of_month' => 1]) 126 | ->save(); 127 | 128 | // Should be blocked on the 1st of each month 129 | expect($user->isAvailableAt('2025-04-01', '14:00', '16:00'))->toBeFalse('April 1st should be blocked'); 130 | expect($user->isAvailableAt('2025-05-01', '14:00', '16:00'))->toBeFalse('May 1st should be blocked'); 131 | expect($user->isAvailableAt('2025-06-01', '14:00', '16:00'))->toBeFalse('June 1st should be blocked'); 132 | 133 | // But available on other days 134 | expect($user->isAvailableAt('2025-04-02', '14:00', '16:00'))->toBeTrue('April 2nd should be available'); 135 | expect($user->isAvailableAt('2025-04-15', '14:00', '16:00'))->toBeTrue('April 15th should be available'); 136 | }); 137 | 138 | it('getAvailableSlots works correctly with recurring schedules', function () { 139 | $user = createUser(); 140 | 141 | // Block afternoon every day 142 | $schedule = Zap::for($user) 143 | ->named('Afternoon Block') 144 | ->from('2025-03-15') 145 | ->addPeriod('13:00', '17:00') 146 | ->daily() 147 | ->save(); 148 | 149 | $slots = $user->getAvailableSlots('2025-03-15', '08:00', '18:00', 60); 150 | 151 | // Check that morning slots are available 152 | $morningSlots = array_filter($slots, fn ($slot) => $slot['start_time'] < '13:00'); 153 | foreach ($morningSlots as $slot) { 154 | expect($slot['is_available'])->toBeTrue( 155 | "Morning slot {$slot['start_time']}-{$slot['end_time']} should be available" 156 | ); 157 | } 158 | 159 | // Check that afternoon slots are blocked 160 | $afternoonSlots = array_filter($slots, fn ($slot) => $slot['start_time'] >= '13:00' && $slot['start_time'] < '17:00'); 161 | foreach ($afternoonSlots as $slot) { 162 | expect($slot['is_available'])->toBeFalse( 163 | "Afternoon slot {$slot['start_time']}-{$slot['end_time']} should be blocked" 164 | ); 165 | } 166 | 167 | // Check that evening slots are available 168 | $eveningSlots = array_filter($slots, fn ($slot) => $slot['start_time'] >= '17:00'); 169 | foreach ($eveningSlots as $slot) { 170 | expect($slot['is_available'])->toBeTrue( 171 | "Evening slot {$slot['start_time']}-{$slot['end_time']} should be available" 172 | ); 173 | } 174 | }); 175 | 176 | it('getNextAvailableSlot works correctly with recurring schedules', function () { 177 | $user = createUser(); 178 | 179 | // Block all working hours on weekdays 180 | $schedule = Zap::for($user) 181 | ->named('Weekday Work') 182 | ->from('2025-03-17') // Next Monday 183 | ->addPeriod('09:00', '17:00') 184 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 185 | ->save(); 186 | 187 | // Looking for next available slot during working hours should find weekend 188 | $nextSlot = $user->getNextAvailableSlot('2025-03-17', 60, '09:00', '17:00'); 189 | 190 | expect($nextSlot)->not->toBeNull('Should find an available slot'); 191 | 192 | // Should be on a weekend 193 | $slotDate = Carbon::parse($nextSlot['date']); 194 | expect($slotDate->isWeekend())->toBeTrue( 195 | 'Next available slot should be on weekend when weekdays are blocked' 196 | ); 197 | }); 198 | 199 | it('handles overlapping time periods correctly with recurring schedules', function () { 200 | $user = createUser(); 201 | 202 | $schedule = Zap::for($user) 203 | ->named('Work Block') 204 | ->from('2025-03-15') 205 | ->addPeriod('09:00', '12:00') 206 | ->daily() 207 | ->save(); 208 | 209 | // Partial overlap scenarios 210 | expect($user->isAvailableAt('2025-03-15', '08:00', '10:00'))->toBeFalse( 211 | 'Should be blocked when overlapping with scheduled time' 212 | ); 213 | expect($user->isAvailableAt('2025-03-15', '11:00', '13:00'))->toBeFalse( 214 | 'Should be blocked when overlapping with scheduled time' 215 | ); 216 | expect($user->isAvailableAt('2025-03-15', '08:00', '13:00'))->toBeFalse( 217 | 'Should be blocked when completely overlapping with scheduled time' 218 | ); 219 | expect($user->isAvailableAt('2025-03-15', '07:00', '08:00'))->toBeTrue( 220 | 'Should be available when completely before scheduled time' 221 | ); 222 | expect($user->isAvailableAt('2025-03-15', '13:00', '14:00'))->toBeTrue( 223 | 'Should be available when completely after scheduled time' 224 | ); 225 | }); 226 | 227 | it('handles schedule end dates correctly', function () { 228 | $user = createUser(); 229 | 230 | // Limited duration recurring schedule 231 | $schedule = Zap::for($user) 232 | ->named('Short Term Project') 233 | ->from('2025-03-15') 234 | ->to('2025-03-21') // Only one week 235 | ->addPeriod('09:00', '17:00') 236 | ->daily() 237 | ->save(); 238 | 239 | // Should be blocked during the schedule period 240 | expect($user->isAvailableAt('2025-03-17', '10:00', '11:00'))->toBeFalse( 241 | 'Should be blocked during active schedule period' 242 | ); 243 | 244 | // Should be available after the schedule ends 245 | expect($user->isAvailableAt('2025-03-22', '10:00', '11:00'))->toBeTrue( 246 | 'Should be available after schedule end date' 247 | ); 248 | }); 249 | 250 | it('handles multiple overlapping recurring schedules', function () { 251 | $user = createUser(); 252 | 253 | // Morning recurring schedule 254 | $morningSchedule = Zap::for($user) 255 | ->named('Morning Routine') 256 | ->from('2025-03-15') 257 | ->addPeriod('08:00', '10:00') 258 | ->daily() 259 | ->save(); 260 | 261 | // Evening recurring schedule (different frequency) 262 | $eveningSchedule = Zap::for($user) 263 | ->named('Evening Classes') 264 | ->from('2025-03-15') 265 | ->addPeriod('18:00', '20:00') 266 | ->weekly(['monday', 'wednesday', 'friday']) 267 | ->save(); 268 | 269 | // Check availability on a Wednesday 270 | expect($user->isAvailableAt('2025-03-19', '08:00', '10:00'))->toBeFalse( 271 | 'Morning should be blocked by daily schedule' 272 | ); 273 | expect($user->isAvailableAt('2025-03-19', '12:00', '14:00'))->toBeTrue( 274 | 'Midday should be available' 275 | ); 276 | expect($user->isAvailableAt('2025-03-19', '18:00', '20:00'))->toBeFalse( 277 | 'Evening should be blocked by weekly schedule on Wednesday' 278 | ); 279 | 280 | // Check availability on a Tuesday (only morning blocked) 281 | expect($user->isAvailableAt('2025-03-18', '08:00', '10:00'))->toBeFalse( 282 | 'Morning should be blocked by daily schedule' 283 | ); 284 | expect($user->isAvailableAt('2025-03-18', '18:00', '20:00'))->toBeTrue( 285 | 'Evening should be available on Tuesday (not in weekly schedule)' 286 | ); 287 | }); 288 | 289 | it('handles edge case time boundaries correctly', function () { 290 | $user = createUser(); 291 | 292 | // Create schedule with specific times 293 | $schedule = Zap::for($user) 294 | ->named('Precise Schedule') 295 | ->from('2025-03-15') 296 | ->addPeriod('09:00', '17:00') 297 | ->daily() 298 | ->save(); 299 | 300 | // Test various time formats and edge cases 301 | expect($user->isAvailableAt('2025-03-15', '08:59', '09:00'))->toBeTrue( 302 | 'Should be available right before schedule starts' 303 | ); 304 | expect($user->isAvailableAt('2025-03-15', '09:00', '09:01'))->toBeFalse( 305 | 'Should be blocked right when schedule starts' 306 | ); 307 | expect($user->isAvailableAt('2025-03-15', '16:59', '17:00'))->toBeFalse( 308 | 'Should be blocked right before schedule ends' 309 | ); 310 | expect($user->isAvailableAt('2025-03-15', '17:00', '17:01'))->toBeTrue( 311 | 'Should be available right when schedule ends' 312 | ); 313 | }); 314 | 315 | }); 316 | -------------------------------------------------------------------------------- /tests/Feature/ReproduceBugsTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Zap\Exceptions\ScheduleConflictException; 4 | use Zap\Facades\Zap; 5 | use Zap\Models\Schedule; 6 | 7 | describe('Reproduce Specific Bugs', function () { 8 | 9 | beforeEach(function () { 10 | config([ 11 | 'zap.conflict_detection.enabled' => true, 12 | 'zap.conflict_detection.buffer_minutes' => 0, 13 | ]); 14 | }); 15 | 16 | describe('Bug 1: False negative - should trigger exception but does not', function () { 17 | 18 | it('should detect overlap when daily schedule conflicts with weekly recurring on specific days', function () { 19 | $user = createUser(); 20 | 21 | // User works Monday, Wednesday, Friday from 8:00-12:00 and 14:00-18:00 22 | Zap::for($user) 23 | ->named('Work Schedule') 24 | ->from('2024-01-01') 25 | ->to('2024-12-31') 26 | ->addPeriod('08:00', '12:00') 27 | ->addPeriod('14:00', '18:00') 28 | ->weekly(['monday', 'wednesday', 'friday']) 29 | ->save(); 30 | 31 | // This SHOULD throw an exception because it overlaps on Mon/Wed/Fri 32 | // But currently it doesn't (FALSE NEGATIVE) 33 | expect(function () use ($user) { 34 | Zap::for($user) 35 | ->named('Daily Afternoon') 36 | ->from('2024-01-01') 37 | ->to('2024-12-31') 38 | ->addPeriod('14:00', '18:00') 39 | ->daily() 40 | ->noOverlap() 41 | ->save(); 42 | })->toThrow(ScheduleConflictException::class); 43 | }); 44 | 45 | }); 46 | 47 | describe('Bug 2: False positive - should NOT trigger exception but does', function () { 48 | 49 | it('should NOT detect overlap when scheduling on non-recurring days', function () { 50 | $user = createUser(); 51 | 52 | // User works Monday, Wednesday, Friday from 8:00-12:00 and 14:00-18:00 53 | Zap::for($user) 54 | ->named('Work Schedule') 55 | ->from('2024-01-01') 56 | ->to('2024-12-31') 57 | ->addPeriod('08:00', '12:00') 58 | ->addPeriod('14:00', '18:00') 59 | ->weekly(['monday', 'wednesday', 'friday']) 60 | ->save(); 61 | 62 | // This SHOULD NOT throw an exception because user doesn't work on Sunday 63 | // But currently it does (FALSE POSITIVE) 64 | $schedule = Zap::for($user) 65 | ->named('Sunday Meeting') 66 | ->from('2024-01-07') // Sunday 67 | ->addPeriod('14:00', '18:00') 68 | ->noOverlap() 69 | ->save(); 70 | 71 | expect($schedule)->toBeInstanceOf(Schedule::class); 72 | expect($schedule->name)->toBe('Sunday Meeting'); 73 | }); 74 | 75 | }); 76 | 77 | describe('Additional tests to validate fixes', function () { 78 | 79 | it('should detect overlap on Tuesday when weekly recurring includes Tuesday', function () { 80 | $user = createUser(); 81 | 82 | // User works Tuesday, Thursday from 9:00-17:00 83 | Zap::for($user) 84 | ->named('Work Schedule') 85 | ->from('2024-01-01') 86 | ->to('2024-12-31') 87 | ->addPeriod('09:00', '17:00') 88 | ->weekly(['tuesday', 'thursday']) 89 | ->save(); 90 | 91 | // This should conflict on Tuesday 92 | expect(function () use ($user) { 93 | Zap::for($user) 94 | ->from('2024-01-02') // Tuesday 95 | ->addPeriod('10:00', '11:00') 96 | ->noOverlap() 97 | ->save(); 98 | })->toThrow(ScheduleConflictException::class); 99 | }); 100 | 101 | it('should NOT detect overlap on Wednesday when weekly recurring excludes Wednesday', function () { 102 | $user = createUser(); 103 | 104 | // User works Tuesday, Thursday from 9:00-17:00 105 | Zap::for($user) 106 | ->named('Work Schedule') 107 | ->from('2024-01-01') 108 | ->to('2024-12-31') 109 | ->addPeriod('09:00', '17:00') 110 | ->weekly(['tuesday', 'thursday']) 111 | ->save(); 112 | 113 | // This should NOT conflict on Wednesday 114 | $schedule = Zap::for($user) 115 | ->from('2024-01-03') // Wednesday 116 | ->addPeriod('10:00', '11:00') 117 | ->noOverlap() 118 | ->save(); 119 | 120 | expect($schedule)->toBeInstanceOf(Schedule::class); 121 | }); 122 | 123 | }); 124 | 125 | }); 126 | -------------------------------------------------------------------------------- /tests/Feature/RuleControlTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Zap\Exceptions\InvalidScheduleException; 4 | use Zap\Exceptions\ScheduleConflictException; 5 | use Zap\Facades\Zap; 6 | use Zap\Models\Schedule; 7 | 8 | describe('Rule Control', function () { 9 | 10 | describe('Individual Rule Control', function () { 11 | 12 | it('can disable working hours rule', function () { 13 | $user = createUser(); 14 | 15 | // This should succeed even though it's outside working hours when rule is explicitly disabled 16 | $schedule = Zap::for($user) 17 | ->from('2025-01-01') 18 | ->addPeriod('18:00', '19:00') // Outside normal working hours 19 | ->withRule('working_hours', [ 20 | 'enabled' => false, 21 | 'start_time' => '09:00', 22 | 'end_time' => '17:00', 23 | ]) 24 | ->save(); 25 | 26 | expect($schedule)->toBeInstanceOf(Schedule::class); 27 | }); 28 | 29 | it('can enable working hours rule', function () { 30 | config(['zap.default_rules.working_hours.enabled' => true]); 31 | 32 | $user = createUser(); 33 | 34 | // This should fail when working hours rule is enabled 35 | expect(function () use ($user) { 36 | Zap::for($user) 37 | ->from('2025-01-01') 38 | ->addPeriod('18:00', '19:00') // Outside working hours 39 | ->workingHoursOnly('09:00', '17:00') 40 | ->save(); 41 | })->toThrow(InvalidScheduleException::class); 42 | }); 43 | 44 | it('can disable max duration rule', function () { 45 | $user = createUser(); 46 | 47 | // This should succeed even though it exceeds max duration when rule is explicitly disabled 48 | $schedule = Zap::for($user) 49 | ->from('2025-01-01') 50 | ->addPeriod('09:00', '18:00') // 9 hours 51 | ->withRule('max_duration', [ 52 | 'enabled' => false, 53 | 'minutes' => 480, 54 | ]) 55 | ->save(); 56 | 57 | expect($schedule)->toBeInstanceOf(Schedule::class); 58 | }); 59 | 60 | it('can enable max duration rule', function () { 61 | config(['zap.default_rules.max_duration.enabled' => true]); 62 | 63 | $user = createUser(); 64 | 65 | // This should fail when max duration rule is enabled 66 | expect(function () use ($user) { 67 | Zap::for($user) 68 | ->from('2025-01-01') 69 | ->addPeriod('09:00', '18:00') // 9 hours 70 | ->maxDuration(480) // 8 hours max 71 | ->save(); 72 | })->toThrow(InvalidScheduleException::class); 73 | }); 74 | 75 | it('can disable no weekends rule', function () { 76 | config(['zap.default_rules.no_weekends.enabled' => false]); 77 | 78 | $user = createUser(); 79 | 80 | // This should succeed even though it's a weekend 81 | $schedule = Zap::for($user) 82 | ->from('2025-01-05') // Sunday 83 | ->addPeriod('09:00', '10:00') 84 | ->noWeekends() 85 | ->save(); 86 | 87 | expect($schedule)->toBeInstanceOf(Schedule::class); 88 | }); 89 | 90 | it('can enable no weekends rule', function () { 91 | config(['zap.default_rules.no_weekends.enabled' => true]); 92 | 93 | $user = createUser(); 94 | 95 | // This should fail when no weekends rule is enabled 96 | expect(function () use ($user) { 97 | Zap::for($user) 98 | ->from('2025-01-05') // Sunday 99 | ->addPeriod('09:00', '10:00') 100 | ->noWeekends() 101 | ->save(); 102 | })->toThrow(InvalidScheduleException::class); 103 | }); 104 | 105 | }); 106 | 107 | describe('No Overlap Rule Control', function () { 108 | 109 | it('can disable no_overlap rule for appointment schedules', function () { 110 | config(['zap.default_rules.no_overlap.enabled' => false]); 111 | 112 | $user = createUser(); 113 | 114 | // Create first appointment 115 | Zap::for($user) 116 | ->named('First Appointment') 117 | ->appointment() 118 | ->from('2025-01-01') 119 | ->addPeriod('09:00', '10:00') 120 | ->save(); 121 | 122 | // This should succeed when no_overlap rule is disabled 123 | $schedule = Zap::for($user) 124 | ->named('Second Appointment') 125 | ->appointment() 126 | ->from('2025-01-01') 127 | ->addPeriod('09:30', '10:30') // Overlaps with first appointment 128 | ->save(); 129 | 130 | expect($schedule)->toBeInstanceOf(Schedule::class); 131 | }); 132 | 133 | it('can enable no_overlap rule for appointment schedules', function () { 134 | config(['zap.default_rules.no_overlap.enabled' => true]); 135 | 136 | $user = createUser(); 137 | 138 | // Create first appointment 139 | Zap::for($user) 140 | ->named('First Appointment') 141 | ->appointment() 142 | ->from('2025-01-01') 143 | ->addPeriod('09:00', '10:00') 144 | ->save(); 145 | 146 | // This should fail when no_overlap rule is enabled 147 | expect(function () use ($user) { 148 | Zap::for($user) 149 | ->named('Second Appointment') 150 | ->appointment() 151 | ->from('2025-01-01') 152 | ->addPeriod('09:30', '10:30') // Overlaps with first appointment 153 | ->save(); 154 | })->toThrow(ScheduleConflictException::class); 155 | }); 156 | 157 | it('can disable no_overlap rule for blocked schedules', function () { 158 | config(['zap.default_rules.no_overlap.enabled' => false]); 159 | 160 | $user = createUser(); 161 | 162 | // Create first blocked schedule 163 | Zap::for($user) 164 | ->named('First Block') 165 | ->blocked() 166 | ->from('2025-01-01') 167 | ->addPeriod('09:00', '10:00') 168 | ->save(); 169 | 170 | // This should succeed when no_overlap rule is disabled 171 | $schedule = Zap::for($user) 172 | ->named('Second Block') 173 | ->blocked() 174 | ->from('2025-01-01') 175 | ->addPeriod('09:30', '10:30') // Overlaps with first block 176 | ->save(); 177 | 178 | expect($schedule)->toBeInstanceOf(Schedule::class); 179 | }); 180 | 181 | it('respects applies_to configuration for no_overlap rule', function () { 182 | config([ 183 | 'zap.default_rules.no_overlap.enabled' => true, 184 | 'zap.default_rules.no_overlap.applies_to' => ['appointment'], // Only apply to appointments 185 | ]); 186 | 187 | $user = createUser(); 188 | 189 | // Create first blocked schedule 190 | Zap::for($user) 191 | ->named('First Block') 192 | ->blocked() 193 | ->from('2025-01-01') 194 | ->addPeriod('09:00', '10:00') 195 | ->save(); 196 | 197 | // This should succeed because no_overlap rule doesn't apply to blocked schedules 198 | $schedule = Zap::for($user) 199 | ->named('Second Block') 200 | ->blocked() 201 | ->from('2025-01-01') 202 | ->addPeriod('09:30', '10:30') // Overlaps with first block 203 | ->save(); 204 | 205 | expect($schedule)->toBeInstanceOf(Schedule::class); 206 | }); 207 | 208 | it('can override no_overlap rule explicitly', function () { 209 | config(['zap.default_rules.no_overlap.enabled' => true]); 210 | 211 | $user = createUser(); 212 | 213 | // Create first appointment 214 | Zap::for($user) 215 | ->named('First Appointment') 216 | ->appointment() 217 | ->from('2025-01-01') 218 | ->addPeriod('09:00', '10:00') 219 | ->save(); 220 | 221 | // This should succeed when explicitly disabling no_overlap 222 | $schedule = Zap::for($user) 223 | ->named('Second Appointment') 224 | ->appointment() 225 | ->from('2025-01-01') 226 | ->addPeriod('09:30', '10:30') // Overlaps with first appointment 227 | ->withRule('no_overlap', ['enabled' => false]) 228 | ->save(); 229 | 230 | expect($schedule)->toBeInstanceOf(Schedule::class); 231 | }); 232 | 233 | }); 234 | 235 | describe('Rule Merging', function () { 236 | 237 | it('merges provided rules with default rules', function () { 238 | config([ 239 | 'zap.default_rules.working_hours.enabled' => true, 240 | 'zap.default_rules.max_duration.enabled' => true, 241 | 'zap.default_rules.no_weekends.enabled' => false, 242 | ]); 243 | 244 | $user = createUser(); 245 | 246 | // This should fail due to default working hours rule 247 | expect(function () use ($user) { 248 | Zap::for($user) 249 | ->from('2025-01-01') 250 | ->addPeriod('18:00', '19:00') // Outside working hours 251 | ->save(); 252 | })->toThrow(InvalidScheduleException::class); 253 | }); 254 | 255 | it('allows overriding default rules with explicit rules', function () { 256 | config([ 257 | 'zap.default_rules.working_hours.enabled' => true, 258 | 'zap.default_rules.working_hours.start' => '09:00', 259 | 'zap.default_rules.working_hours.end' => '17:00', 260 | ]); 261 | 262 | $user = createUser(); 263 | 264 | // This should succeed with custom working hours 265 | $schedule = Zap::for($user) 266 | ->from('2025-01-01') 267 | ->addPeriod('18:00', '19:00') // Outside default working hours 268 | ->workingHoursOnly('17:00', '20:00') // Custom working hours 269 | ->save(); 270 | 271 | expect($schedule)->toBeInstanceOf(Schedule::class); 272 | }); 273 | 274 | it('can disable default rules with explicit false', function () { 275 | config([ 276 | 'zap.default_rules.working_hours.enabled' => true, 277 | 'zap.default_rules.working_hours.start' => '09:00', 278 | 'zap.default_rules.working_hours.end' => '17:00', 279 | ]); 280 | 281 | $user = createUser(); 282 | 283 | // This should succeed by explicitly disabling working hours 284 | $schedule = Zap::for($user) 285 | ->from('2025-01-01') 286 | ->addPeriod('18:00', '19:00') // Outside working hours 287 | ->withRule('working_hours', ['enabled' => false]) 288 | ->save(); 289 | 290 | expect($schedule)->toBeInstanceOf(Schedule::class); 291 | }); 292 | 293 | }); 294 | 295 | describe('Global Conflict Detection Control', function () { 296 | 297 | it('can disable all conflict detection', function () { 298 | config(['zap.conflict_detection.enabled' => false]); 299 | 300 | $user = createUser(); 301 | 302 | // Create first appointment 303 | Zap::for($user) 304 | ->named('First Appointment') 305 | ->appointment() 306 | ->from('2025-01-01') 307 | ->addPeriod('09:00', '10:00') 308 | ->save(); 309 | 310 | // This should succeed when conflict detection is disabled 311 | $schedule = Zap::for($user) 312 | ->named('Second Appointment') 313 | ->appointment() 314 | ->from('2025-01-01') 315 | ->addPeriod('09:30', '10:30') // Overlaps with first appointment 316 | ->save(); 317 | 318 | expect($schedule)->toBeInstanceOf(Schedule::class); 319 | }); 320 | 321 | it('respects global conflict detection setting over rule settings', function () { 322 | config([ 323 | 'zap.conflict_detection.enabled' => false, 324 | 'zap.default_rules.no_overlap.enabled' => true, 325 | ]); 326 | 327 | $user = createUser(); 328 | 329 | // Create first appointment 330 | Zap::for($user) 331 | ->named('First Appointment') 332 | ->appointment() 333 | ->from('2025-01-01') 334 | ->addPeriod('09:00', '10:00') 335 | ->save(); 336 | 337 | // This should succeed because global conflict detection is disabled 338 | $schedule = Zap::for($user) 339 | ->named('Second Appointment') 340 | ->appointment() 341 | ->from('2025-01-01') 342 | ->addPeriod('09:30', '10:30') // Overlaps with first appointment 343 | ->save(); 344 | 345 | expect($schedule)->toBeInstanceOf(Schedule::class); 346 | }); 347 | 348 | }); 349 | 350 | }); 351 | -------------------------------------------------------------------------------- /tests/Feature/ScheduleManagementTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Zap\Exceptions\InvalidScheduleException; 4 | use Zap\Exceptions\ScheduleConflictException; 5 | use Zap\Facades\Zap; 6 | use Zap\Models\Schedule; 7 | 8 | it('can create a basic schedule', function () { 9 | $user = createUser(); 10 | 11 | $schedule = Zap::for($user) 12 | ->named('Test Schedule') 13 | ->from('2025-01-01') 14 | ->addPeriod('09:00', '10:00') 15 | ->save(); 16 | 17 | expect($schedule)->toBeInstanceOf(Schedule::class); 18 | expect($schedule->name)->toBe('Test Schedule'); 19 | expect($schedule->start_date->format('Y-m-d'))->toBe('2025-01-01'); 20 | expect($schedule->periods)->toHaveCount(1); 21 | }); 22 | 23 | it('can create recurring weekly schedule', function () { 24 | $user = createUser(); 25 | 26 | $schedule = Zap::for($user) 27 | ->named('Weekly Meeting') 28 | ->from('2025-01-01') 29 | ->to('2025-12-31') 30 | ->addPeriod('09:00', '10:00') 31 | ->weekly(['monday', 'wednesday', 'friday']) 32 | ->save(); 33 | 34 | expect($schedule->is_recurring)->toBeTrue(); 35 | expect($schedule->frequency)->toBe('weekly'); 36 | expect($schedule->frequency_config)->toBe(['days' => ['monday', 'wednesday', 'friday']]); 37 | }); 38 | 39 | it('detects schedule conflicts', function () { 40 | $user = createUser(); 41 | 42 | // Create first schedule 43 | Zap::for($user) 44 | ->from('2025-01-01') 45 | ->addPeriod('09:00', '10:00') 46 | ->noOverlap() 47 | ->save(); 48 | 49 | // Try to create conflicting schedule 50 | expect(function () use ($user) { 51 | Zap::for($user) 52 | ->from('2025-01-01') 53 | ->addPeriod('09:30', '10:30') // Overlaps with first schedule 54 | ->noOverlap() 55 | ->save(); 56 | })->toThrow(ScheduleConflictException::class); 57 | }); 58 | 59 | it('can check availability', function () { 60 | $user = createUser(); 61 | 62 | // Create a schedule 63 | Zap::for($user) 64 | ->from('2025-01-01') 65 | ->addPeriod('09:00', '10:00') 66 | ->save(); 67 | 68 | // Check availability 69 | expect($user->isAvailableAt('2025-01-01', '09:00', '10:00'))->toBeFalse(); 70 | expect($user->isAvailableAt('2025-01-01', '10:00', '11:00'))->toBeTrue(); 71 | }); 72 | 73 | it('can get available slots', function () { 74 | $user = createUser(); 75 | 76 | // Create a schedule that blocks 09:00-10:00 77 | Zap::for($user) 78 | ->from('2025-01-01') 79 | ->addPeriod('09:00', '10:00') 80 | ->save(); 81 | 82 | $slots = $user->getAvailableSlots('2025-01-01', '09:00', '12:00', 60); 83 | 84 | expect($slots)->toBeArray(); 85 | expect($slots[0]['is_available'])->toBeFalse(); // 09:00-10:00 should be unavailable 86 | expect($slots[1]['is_available'])->toBeTrue(); // 10:00-11:00 should be available 87 | expect($slots[2]['is_available'])->toBeTrue(); // 11:00-12:00 should be available 88 | }); 89 | 90 | it('can find next available slot', function () { 91 | $user = createUser(); 92 | 93 | // Create a schedule that blocks the morning 94 | Zap::for($user) 95 | ->from('2025-01-01') 96 | ->addPeriod('09:00', '12:00') 97 | ->save(); 98 | 99 | $nextSlot = $user->getNextAvailableSlot('2025-01-01', 60, '09:00', '17:00'); 100 | 101 | expect($nextSlot)->toBeArray(); 102 | expect($nextSlot['date'])->toBe('2025-01-01'); 103 | expect($nextSlot['start_time'])->toBe('12:00'); // First available after the blocked period 104 | }); 105 | 106 | it('respects working hours rule', function () { 107 | $user = createUser(); 108 | 109 | expect(function () use ($user) { 110 | Zap::for($user) 111 | ->from('2025-01-01') 112 | ->addPeriod('18:00', '19:00') // Outside working hours 113 | ->workingHoursOnly('09:00', '17:00') 114 | ->save(); 115 | })->toThrow(InvalidScheduleException::class); 116 | }); 117 | 118 | it('can handle schedule metadata', function () { 119 | $user = createUser(); 120 | 121 | $schedule = Zap::for($user) 122 | ->named('Meeting with Client') 123 | ->description('Important client meeting') 124 | ->from('2025-01-01') 125 | ->addPeriod('09:00', '10:00') 126 | ->withMetadata([ 127 | 'location' => 'Conference Room A', 128 | 'attendees' => ['john@example.com', 'jane@example.com'], 129 | 'priority' => 'high', 130 | ]) 131 | ->save(); 132 | 133 | expect($schedule->metadata['location'])->toBe('Conference Room A'); 134 | expect($schedule->metadata['attendees'])->toHaveCount(2); 135 | expect($schedule->metadata['priority'])->toBe('high'); 136 | }); 137 | 138 | it('can create complex recurring schedule', function () { 139 | $user = createUser(); 140 | 141 | $schedule = Zap::for($user) 142 | ->named('Office Hours') 143 | ->description('Available for consultations') 144 | ->from('2025-01-01') 145 | ->to('2025-06-30') 146 | ->addPeriod('09:00', '12:00') // Morning session 147 | ->addPeriod('14:00', '17:00') // Afternoon session 148 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 149 | ->noWeekends() 150 | ->save(); 151 | 152 | expect($schedule->is_recurring)->toBeTrue(); 153 | expect($schedule->frequency)->toBe('weekly'); 154 | expect($schedule->periods)->toHaveCount(2); 155 | expect($schedule->description)->toBe('Available for consultations'); 156 | }); 157 | 158 | it('can validate schedule periods', function () { 159 | $user = createUser(); 160 | 161 | expect(function () use ($user) { 162 | Zap::for($user) 163 | ->from('2025-01-01') 164 | ->addPeriod('10:00', '09:00') // End time before start time 165 | ->save(); 166 | })->toThrow(InvalidScheduleException::class); 167 | }); 168 | 169 | it('can check for schedule conflicts without saving', function () { 170 | $user = createUser(); 171 | 172 | // Create first schedule 173 | $schedule1 = Zap::for($user) 174 | ->from('2025-01-01') 175 | ->addPeriod('09:00', '10:00') 176 | ->save(); 177 | 178 | // Create another schedule that would conflict 179 | $schedule2 = Zap::for($user) 180 | ->from('2025-01-01') 181 | ->addPeriod('09:30', '10:30') 182 | ->build(); 183 | 184 | $conflicts = Zap::findConflicts($schedule1); 185 | expect($conflicts)->toBeArray(); 186 | }); 187 | 188 | it('supports different schedulable types', function () { 189 | $user = createUser(); 190 | $room = createRoom(); 191 | 192 | // Schedule for user 193 | $userSchedule = Zap::for($user) 194 | ->named('User Meeting') 195 | ->from('2025-01-01') 196 | ->addPeriod('09:00', '10:00') 197 | ->save(); 198 | 199 | // Schedule for room 200 | $roomSchedule = Zap::for($room) 201 | ->named('Room Booking') 202 | ->from('2025-01-01') 203 | ->addPeriod('11:00', '12:00') 204 | ->save(); 205 | 206 | expect($userSchedule->schedulable_type)->toContain('Model@anonymous'); 207 | expect($roomSchedule->schedulable_type)->toContain('Model@anonymous'); 208 | expect($userSchedule->schedulable_id)->toBe(1); 209 | expect($roomSchedule->schedulable_id)->toBe(1); 210 | }); 211 | 212 | it('can handle monthly recurring schedules', function () { 213 | $user = createUser(); 214 | 215 | $schedule = Zap::for($user) 216 | ->named('Monthly Review') 217 | ->from('2025-01-01') 218 | ->to('2025-12-31') 219 | ->addPeriod('14:00', '15:00') 220 | ->monthly(['day_of_month' => 1]) 221 | ->save(); 222 | 223 | expect($schedule->is_recurring)->toBeTrue(); 224 | expect($schedule->frequency)->toBe('monthly'); 225 | expect($schedule->frequency_config)->toBe(['day_of_month' => 1]); 226 | }); 227 | 228 | it('validates maximum duration rule', function () { 229 | $user = createUser(); 230 | 231 | expect(function () use ($user) { 232 | Zap::for($user) 233 | ->from('2025-01-01') 234 | ->addPeriod('09:00', '18:00') // 9 hour period 235 | ->maxDuration(480) // 8 hours max 236 | ->save(); 237 | })->toThrow(InvalidScheduleException::class); 238 | }); 239 | -------------------------------------------------------------------------------- /tests/Feature/ScheduleTypesTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Zap\Enums\ScheduleTypes; 4 | use Zap\Exceptions\ScheduleConflictException; 5 | use Zap\Facades\Zap; 6 | use Zap\Models\Schedule; 7 | 8 | it('can create availability schedules that allow overlaps', function () { 9 | $user = createUser(); 10 | 11 | // Create an availability schedule (working hours) 12 | $availability = Zap::for($user) 13 | ->named('Working Hours') 14 | ->description('Available for appointments') 15 | ->availability() 16 | ->from('2025-01-01') 17 | ->addPeriod('09:00', '17:00') 18 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 19 | ->save(); 20 | 21 | expect($availability->schedule_type)->toBe(ScheduleTypes::AVAILABILITY); 22 | expect($availability->allowsOverlaps())->toBeTrue(); 23 | expect($availability->preventsOverlaps())->toBeFalse(); 24 | 25 | // Create an appointment within the availability window 26 | $appointment = Zap::for($user) 27 | ->named('Client Meeting') 28 | ->description('Meeting with client') 29 | ->appointment() 30 | ->from('2025-01-01') 31 | ->addPeriod('10:00', '11:00') 32 | ->save(); 33 | 34 | expect($appointment->schedule_type)->toBe(ScheduleTypes::APPOINTMENT); 35 | expect($appointment->allowsOverlaps())->toBeFalse(); 36 | expect($appointment->preventsOverlaps())->toBeTrue(); 37 | 38 | // Should NOT be able to create another appointment in the same time slot 39 | expect(function () use ($user) { 40 | Zap::for($user) 41 | ->named('Another Meeting') 42 | ->description('Another meeting') 43 | ->appointment() 44 | ->from('2025-01-01') 45 | ->addPeriod('10:00', '11:00') 46 | ->save(); 47 | })->toThrow(ScheduleConflictException::class); 48 | }); 49 | 50 | it('can create appointment schedules that prevent overlaps', function () { 51 | $user = createUser(); 52 | 53 | // Create first appointment 54 | $appointment1 = Zap::for($user) 55 | ->named('First Appointment') 56 | ->appointment() 57 | ->from('2025-01-01') 58 | ->addPeriod('10:00', '11:00') 59 | ->save(); 60 | 61 | // Try to create overlapping appointment - should fail 62 | expect(function () use ($user) { 63 | Zap::for($user) 64 | ->named('Conflicting Appointment') 65 | ->appointment() 66 | ->from('2025-01-01') 67 | ->addPeriod('10:30', '11:30') 68 | ->save(); 69 | })->toThrow(ScheduleConflictException::class); 70 | 71 | // Create non-overlapping appointment - should succeed 72 | $appointment2 = Zap::for($user) 73 | ->named('Non-Conflicting Appointment') 74 | ->appointment() 75 | ->from('2025-01-01') 76 | ->addPeriod('11:00', '12:00') 77 | ->save(); 78 | 79 | expect($appointment2->schedule_type)->toBe(ScheduleTypes::APPOINTMENT); 80 | }); 81 | 82 | it('can create blocked schedules that prevent overlaps', function () { 83 | $user = createUser(); 84 | 85 | // Create a blocked schedule (lunch break) 86 | $blocked = Zap::for($user) 87 | ->named('Lunch Break') 88 | ->description('Unavailable for appointments') 89 | ->blocked() 90 | ->from('2025-01-01') 91 | ->addPeriod('12:00', '13:00') 92 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 93 | ->save(); 94 | 95 | expect($blocked->schedule_type)->toBe(ScheduleTypes::BLOCKED); 96 | expect($blocked->allowsOverlaps())->toBeFalse(); 97 | expect($blocked->preventsOverlaps())->toBeTrue(); 98 | 99 | // Try to create appointment during blocked time - should fail 100 | expect(function () use ($user) { 101 | Zap::for($user) 102 | ->named('Lunch Meeting') 103 | ->appointment() 104 | ->from('2025-01-01') 105 | ->addPeriod('12:00', '13:00') 106 | ->save(); 107 | })->toThrow(ScheduleConflictException::class); 108 | }); 109 | 110 | it('can use convenience methods for schedule types', function () { 111 | $user = createUser(); 112 | 113 | // Test availability method 114 | $availability = Zap::for($user) 115 | ->availability() 116 | ->from('2025-01-01') 117 | ->addPeriod('09:00', '17:00') 118 | ->save(); 119 | 120 | expect($availability->schedule_type)->toBe(ScheduleTypes::AVAILABILITY); 121 | 122 | // Test appointment method - use different date to avoid conflicts 123 | $appointment = Zap::for($user) 124 | ->appointment() 125 | ->from('2025-01-02') 126 | ->addPeriod('10:00', '11:00') 127 | ->save(); 128 | 129 | expect($appointment->schedule_type)->toBe(ScheduleTypes::APPOINTMENT); 130 | 131 | // Test blocked method - use different date to avoid conflicts 132 | $blocked = Zap::for($user) 133 | ->blocked() 134 | ->from('2025-01-03') 135 | ->addPeriod('12:00', '13:00') 136 | ->save(); 137 | 138 | expect($blocked->schedule_type)->toBe(ScheduleTypes::BLOCKED); 139 | 140 | // Test custom method - use different date to avoid conflicts 141 | $custom = Zap::for($user) 142 | ->custom() 143 | ->from('2025-01-04') 144 | ->addPeriod('14:00', '15:00') 145 | ->save(); 146 | 147 | expect($custom->schedule_type)->toBe(ScheduleTypes::CUSTOM); 148 | }); 149 | 150 | it('can use explicit type method', function () { 151 | $user = createUser(); 152 | 153 | $schedule = Zap::for($user) 154 | ->type('availability') 155 | ->from('2025-01-01') 156 | ->addPeriod('09:00', '17:00') 157 | ->save(); 158 | 159 | expect($schedule->schedule_type)->toBe(ScheduleTypes::AVAILABILITY); 160 | 161 | // Test invalid type 162 | expect(function () use ($user) { 163 | Zap::for($user) 164 | ->type('invalid_type') 165 | ->from('2025-01-01') 166 | ->addPeriod('09:00', '17:00') 167 | ->save(); 168 | })->toThrow(InvalidArgumentException::class); 169 | }); 170 | 171 | it('can query schedules by type', function () { 172 | $user = createUser(); 173 | 174 | // Create different types of schedules on different dates to avoid conflicts 175 | $availability = Zap::for($user)->availability()->from('2025-01-01')->addPeriod('09:00', '17:00')->save(); 176 | $appointment = Zap::for($user)->appointment()->from('2025-01-02')->addPeriod('10:00', '11:00')->save(); 177 | $blocked = Zap::for($user)->blocked()->from('2025-01-03')->addPeriod('12:00', '13:00')->save(); 178 | $custom = Zap::for($user)->custom()->from('2025-01-04')->addPeriod('14:00', '15:00')->save(); 179 | 180 | // Test individual schedule types 181 | expect($availability->schedule_type)->toBe(ScheduleTypes::AVAILABILITY); 182 | expect($appointment->schedule_type)->toBe(ScheduleTypes::APPOINTMENT); 183 | expect($blocked->schedule_type)->toBe(ScheduleTypes::BLOCKED); 184 | expect($custom->schedule_type)->toBe(ScheduleTypes::CUSTOM); 185 | 186 | // Test helper methods 187 | expect($availability->isAvailability())->toBeTrue(); 188 | expect($appointment->isAppointment())->toBeTrue(); 189 | expect($blocked->isBlocked())->toBeTrue(); 190 | expect($custom->isCustom())->toBeTrue(); 191 | 192 | // Test overlap behavior 193 | expect($availability->allowsOverlaps())->toBeTrue(); 194 | expect($appointment->preventsOverlaps())->toBeTrue(); 195 | expect($blocked->preventsOverlaps())->toBeTrue(); 196 | expect($custom->allowsOverlaps())->toBeTrue(); 197 | }); 198 | 199 | it('handles availability checking correctly with new schedule types', function () { 200 | $user = createUser(); 201 | 202 | // Create availability schedule (working hours) 203 | Zap::for($user) 204 | ->availability() 205 | ->from('2025-01-01') 206 | ->addPeriod('09:00', '17:00') 207 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 208 | ->save(); 209 | 210 | // Create appointment 211 | Zap::for($user) 212 | ->appointment() 213 | ->from('2025-01-01') 214 | ->addPeriod('10:00', '11:00') 215 | ->save(); 216 | 217 | // Create blocked time 218 | Zap::for($user) 219 | ->blocked() 220 | ->from('2025-01-01') 221 | ->addPeriod('12:00', '13:00') 222 | ->save(); 223 | 224 | // Test availability checking 225 | expect($user->isAvailableAt('2025-01-01', '09:00', '10:00'))->toBeTrue(); // Available (before appointment) 226 | expect($user->isAvailableAt('2025-01-01', '10:00', '11:00'))->toBeFalse(); // Appointment blocks 227 | expect($user->isAvailableAt('2025-01-01', '11:00', '12:00'))->toBeTrue(); // Available 228 | expect($user->isAvailableAt('2025-01-01', '12:00', '13:00'))->toBeFalse(); // Blocked 229 | expect($user->isAvailableAt('2025-01-01', '13:00', '14:00'))->toBeTrue(); // Available 230 | expect($user->isAvailableAt('2025-01-01', '17:00', '18:00'))->toBeTrue(); // Outside working hours but no blocking schedules 231 | }); 232 | 233 | it('can create complex scheduling scenarios', function () { 234 | $doctor = createUser(); 235 | 236 | // Doctor's working hours (availability) 237 | $availability = Zap::for($doctor) 238 | ->named('Office Hours') 239 | ->availability() 240 | ->from('2025-01-01') 241 | ->to('2025-12-31') 242 | ->addPeriod('09:00', '12:00') // Morning session 243 | ->addPeriod('14:00', '17:00') // Afternoon session 244 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 245 | ->save(); 246 | 247 | // Lunch break (blocked) 248 | $lunchBreak = Zap::for($doctor) 249 | ->named('Lunch Break') 250 | ->blocked() 251 | ->from('2025-01-01') 252 | ->to('2025-12-31') 253 | ->addPeriod('12:00', '13:00') 254 | ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) 255 | ->save(); 256 | 257 | // Patient appointments (non-overlapping) 258 | $appointment1 = Zap::for($doctor) 259 | ->named('Patient A - Checkup') 260 | ->appointment() 261 | ->from('2025-01-01') 262 | ->addPeriod('10:00', '11:00') 263 | ->withMetadata(['patient_id' => 1, 'type' => 'checkup']) 264 | ->save(); 265 | 266 | $appointment2 = Zap::for($doctor) 267 | ->named('Patient B - Consultation') 268 | ->appointment() 269 | ->from('2025-01-01') 270 | ->addPeriod('15:00', '16:00') 271 | ->withMetadata(['patient_id' => 2, 'type' => 'consultation']) 272 | ->save(); 273 | 274 | // Verify each schedule was created successfully 275 | expect($availability)->toBeInstanceOf(Schedule::class) 276 | ->and($lunchBreak)->toBeInstanceOf(Schedule::class) 277 | ->and($appointment1)->toBeInstanceOf(Schedule::class) 278 | ->and($appointment2)->toBeInstanceOf(Schedule::class) 279 | ->and($availability->schedule_type)->toBe(ScheduleTypes::AVAILABILITY) 280 | ->and($lunchBreak->schedule_type)->toBe(ScheduleTypes::BLOCKED) 281 | ->and($appointment1->schedule_type)->toBe(ScheduleTypes::APPOINTMENT) 282 | ->and($appointment2->schedule_type)->toBe(ScheduleTypes::APPOINTMENT) 283 | ->and($doctor->isAvailableAt('2025-01-01', '09:00', '10:00'))->toBeTrue() 284 | ->and($doctor->isAvailableAt('2025-01-01', '10:00', '11:00'))->toBeFalse() 285 | ->and($doctor->isAvailableAt('2025-01-01', '11:00', '12:00'))->toBeTrue() 286 | ->and($doctor->isAvailableAt('2025-01-01', '12:00', '13:00'))->toBeFalse() 287 | ->and($doctor->isAvailableAt('2025-01-01', '13:00', '14:00'))->toBeTrue() 288 | ->and($doctor->isAvailableAt('2025-01-01', '15:00', '16:00'))->toBeFalse() 289 | ->and($doctor->isAvailableAt('2025-01-01', '16:00', '17:00'))->toBeTrue(); 290 | 291 | // Verify schedule types 292 | 293 | // Test availability 294 | // Available (before appointment) 295 | // Appointment 296 | // Available 297 | // Lunch break 298 | // Available 299 | // Appointment 300 | // Available 301 | }); 302 | 303 | it('maintains backward compatibility with existing code', function () { 304 | $user = createUser(); 305 | 306 | // Old way of creating schedules (should default to 'custom' type) 307 | $schedule = Zap::for($user) 308 | ->from('2025-01-01') 309 | ->addPeriod('09:00', '10:00') 310 | ->save(); 311 | 312 | expect($schedule->schedule_type)->toBe(ScheduleTypes::CUSTOM); 313 | expect($schedule->isCustom())->toBeTrue(); 314 | 315 | // Old way with noOverlap() should still work 316 | $appointment = Zap::for($user) 317 | ->from('2025-01-01') 318 | ->addPeriod('10:00', '11:00') 319 | ->noOverlap() 320 | ->save(); 321 | 322 | expect($appointment->schedule_type)->toBe(ScheduleTypes::CUSTOM); 323 | expect($appointment->preventsOverlaps())->toBeFalse(); // Custom type doesn't prevent overlaps by default 324 | }); 325 | -------------------------------------------------------------------------------- /tests/Feature/SlotsEdgeCasesTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Carbon\Carbon; 4 | use Zap\Facades\Zap; 5 | 6 | describe('Slots Feature Edge Cases', function () { 7 | 8 | beforeEach(function () { 9 | Carbon::setTestNow('2025-03-14 08:00:00'); // Friday 10 | }); 11 | 12 | afterEach(function () { 13 | Carbon::setTestNow(); // Reset 14 | }); 15 | 16 | describe('Cross-midnight scenarios', function () { 17 | 18 | it('handles schedules that cross midnight', function () { 19 | $user = createUser(); 20 | 21 | // Schedule from 22:00 to 23:59 on Saturday 22 | Zap::for($user) 23 | ->from('2025-03-15') 24 | ->addPeriod('22:00', '23:59') 25 | ->save(); 26 | 27 | // Schedule from 00:00 to 02:00 on Sunday 28 | Zap::for($user) 29 | ->from('2025-03-16') 30 | ->addPeriod('00:00', '02:00') 31 | ->save(); 32 | 33 | // Check evening slots on Saturday - only test existing slots 34 | $eveningSlots = $user->getAvailableSlots('2025-03-15', '21:00', '23:00', 60); 35 | expect(count($eveningSlots))->toBeGreaterThan(0); 36 | expect($eveningSlots[1]['is_available'])->toBeFalse(); // 22:00-23:00 blocked 37 | 38 | // Check early morning slots on Sunday 39 | $morningSlots = $user->getAvailableSlots('2025-03-16', '00:00', '03:00', 60); 40 | expect($morningSlots[0]['is_available'])->toBeFalse(); // 00:00-01:00 blocked 41 | expect($morningSlots[1]['is_available'])->toBeFalse(); // 01:00-02:00 blocked 42 | expect($morningSlots[2]['is_available'])->toBeTrue(); // 02:00-03:00 available 43 | }); 44 | 45 | }); 46 | 47 | describe('Stress testing', function () { 48 | 49 | it('handles many small slots efficiently', function () { 50 | $user = createUser(); 51 | 52 | // Block random hours throughout the day 53 | for ($i = 10; $i < 17; $i += 2) { 54 | Zap::for($user) 55 | ->from('2025-03-15') 56 | ->addPeriod(sprintf('%02d:00', $i), sprintf('%02d:30', $i)) 57 | ->save(); 58 | } 59 | 60 | $startTime = microtime(true); 61 | 62 | // Get 15-minute slots for entire day (should create 96 slots) 63 | $slots = $user->getAvailableSlots('2025-03-15', '00:00', '23:59', 15); 64 | 65 | $executionTime = microtime(true) - $startTime; 66 | 67 | expect(count($slots))->toBeGreaterThan(90); // Should have many slots 68 | expect($executionTime)->toBeLessThan(0.5); // Should complete quickly 69 | 70 | // Verify some blocked slots 71 | $blockedSlots = array_filter($slots, fn ($slot) => ! $slot['is_available']); 72 | expect(count($blockedSlots))->toBeGreaterThan(5); // Should have some blocked slots 73 | }); 74 | 75 | it('handles long duration searches efficiently', function () { 76 | $user = createUser(); 77 | 78 | // Block only small portions, leaving enough space for longer slots 79 | Zap::for($user) 80 | ->from('2025-03-15') 81 | ->addPeriod('10:00', '10:30') 82 | ->save(); 83 | 84 | $startTime = microtime(true); 85 | 86 | // Look for 2-hour slot (reasonable duration that should be found) 87 | $nextSlot = $user->getNextAvailableSlot('2025-03-15', 120, '09:00', '19:00'); 88 | 89 | $executionTime = microtime(true) - $startTime; 90 | 91 | expect($nextSlot)->not->toBeNull(); 92 | expect($executionTime)->toBeLessThan(1.0); // Should complete in reasonable time 93 | }); 94 | 95 | }); 96 | 97 | describe('Invalid input handling', function () { 98 | 99 | it('handles invalid time ranges gracefully', function () { 100 | $user = createUser(); 101 | 102 | // End time before start time 103 | $slots = $user->getAvailableSlots('2025-03-15', '17:00', '09:00', 60); 104 | expect($slots)->toBeArray(); 105 | expect(count($slots))->toBe(0); // Should return empty array 106 | 107 | // Invalid slot duration 108 | $slots2 = $user->getAvailableSlots('2025-03-15', '09:00', '17:00', 0); 109 | expect($slots2)->toBeArray(); 110 | expect(count($slots2))->toBe(0); // Should return empty array 111 | 112 | // Negative slot duration 113 | $slots3 = $user->getAvailableSlots('2025-03-15', '09:00', '17:00', -60); 114 | expect($slots3)->toBeArray(); 115 | expect(count($slots3))->toBe(0); // Should return empty array 116 | }); 117 | 118 | it('handles invalid dates gracefully', function () { 119 | $user = createUser(); 120 | 121 | // Past dates 122 | $slots = $user->getAvailableSlots('2020-01-01', '09:00', '17:00', 60); 123 | expect($slots)->toBeArray(); // Should not crash 124 | 125 | // Invalid date format (should not crash, but may return empty) 126 | try { 127 | $slots2 = $user->getAvailableSlots('invalid-date', '09:00', '17:00', 60); 128 | expect($slots2)->toBeArray(); 129 | } catch (Exception $e) { 130 | // Expected behavior - invalid date should throw exception 131 | expect($e)->toBeInstanceOf(Exception::class); 132 | } 133 | }); 134 | 135 | }); 136 | 137 | describe('Timezone considerations', function () { 138 | 139 | it('handles consistent timezone behavior', function () { 140 | $user = createUser(); 141 | 142 | // Schedule at specific time 143 | Zap::for($user) 144 | ->from('2025-03-15') 145 | ->addPeriod('14:00', '15:00') 146 | ->save(); 147 | 148 | // Get slots in same timezone 149 | $slots = $user->getAvailableSlots('2025-03-15', '13:00', '16:00', 60); 150 | 151 | expect($slots[0]['is_available'])->toBeTrue(); // 13:00-14:00 available 152 | expect($slots[1]['is_available'])->toBeFalse(); // 14:00-15:00 blocked 153 | expect($slots[2]['is_available'])->toBeTrue(); // 15:00-16:00 available 154 | }); 155 | 156 | }); 157 | 158 | describe('Complex recurring patterns', function () { 159 | 160 | it('handles multiple overlapping weekly patterns', function () { 161 | $user = createUser(); 162 | 163 | // Monday/Wednesday/Friday morning 164 | Zap::for($user) 165 | ->from('2025-03-17') // Monday 166 | ->addPeriod('09:00', '12:00') 167 | ->weekly(['monday', 'wednesday', 'friday']) 168 | ->save(); 169 | 170 | // Tuesday/Thursday afternoon 171 | Zap::for($user) 172 | ->from('2025-03-18') // Tuesday 173 | ->addPeriod('13:00', '17:00') 174 | ->weekly(['tuesday', 'thursday']) 175 | ->save(); 176 | 177 | // Test Monday (should block morning) 178 | $mondaySlots = $user->getAvailableSlots('2025-03-17', '08:00', '18:00', 60); 179 | expect($mondaySlots[1]['is_available'])->toBeFalse(); // 09:00-10:00 blocked 180 | expect($mondaySlots[5]['is_available'])->toBeTrue(); // 13:00-14:00 available 181 | 182 | // Test Tuesday (should block afternoon) 183 | $tuesdaySlots = $user->getAvailableSlots('2025-03-18', '08:00', '18:00', 60); 184 | expect($tuesdaySlots[1]['is_available'])->toBeTrue(); // 09:00-10:00 available 185 | expect($tuesdaySlots[5]['is_available'])->toBeFalse(); // 13:00-14:00 blocked 186 | }); 187 | 188 | it('handles bi-weekly patterns', function () { 189 | $user = createUser(); 190 | 191 | // Every other Saturday 192 | Zap::for($user) 193 | ->from('2025-03-15') // First Saturday 194 | ->addPeriod('10:00', '16:00') 195 | ->weekly(['saturday'], 2) // Every 2 weeks 196 | ->save(); 197 | 198 | // March 15 (first Saturday) - should be blocked 199 | $firstSaturday = $user->getAvailableSlots('2025-03-15', '09:00', '17:00', 60); 200 | $blockedSlots = array_filter($firstSaturday, fn ($slot) => ! $slot['is_available']); 201 | expect(count($blockedSlots))->toBeGreaterThan(0); 202 | 203 | // March 22 (second Saturday) - should be available (bi-weekly means every 2 weeks) 204 | $secondSaturday = $user->getAvailableSlots('2025-03-22', '09:00', '17:00', 60); 205 | $availableSlots = array_filter($secondSaturday, fn ($slot) => $slot['is_available']); 206 | expect(count($availableSlots))->toBeGreaterThan(0); // Should have some available slots 207 | 208 | // March 29 (third Saturday, which is week 2 of cycle) - should be blocked 209 | $thirdSaturday = $user->getAvailableSlots('2025-03-29', '09:00', '17:00', 60); 210 | $blockedSlots3 = array_filter($thirdSaturday, fn ($slot) => ! $slot['is_available']); 211 | expect(count($blockedSlots3))->toBeGreaterThan(0); 212 | }); 213 | 214 | }); 215 | 216 | describe('Business logic edge cases', function () { 217 | 218 | it('handles very short slots correctly', function () { 219 | $user = createUser(); 220 | 221 | // Block 10:05-10:15 (10-minute block) 222 | Zap::for($user) 223 | ->from('2025-03-15') 224 | ->addPeriod('10:05', '10:15') 225 | ->save(); 226 | 227 | // 5-minute slots should detect the conflict 228 | $slots5min = $user->getAvailableSlots('2025-03-15', '10:00', '10:20', 5); 229 | 230 | // Should have slots: 10:00-05, 10:05-10, 10:10-15, 10:15-20 231 | expect(count($slots5min))->toBe(4); 232 | expect($slots5min[0]['is_available'])->toBeTrue(); // 10:00-10:05 available 233 | expect($slots5min[1]['is_available'])->toBeFalse(); // 10:05-10:10 blocked 234 | expect($slots5min[2]['is_available'])->toBeFalse(); // 10:10-10:15 blocked 235 | expect($slots5min[3]['is_available'])->toBeTrue(); // 10:15-10:20 available 236 | }); 237 | 238 | it('handles exact time boundary matches', function () { 239 | $user = createUser(); 240 | 241 | // Schedule exactly 10:00-11:00 242 | Zap::for($user) 243 | ->from('2025-03-15') 244 | ->addPeriod('10:00', '11:00') 245 | ->save(); 246 | 247 | // Request slot exactly 10:00-11:00 248 | $slots = $user->getAvailableSlots('2025-03-15', '10:00', '11:00', 60); 249 | expect($slots)->toHaveCount(1); 250 | expect($slots[0]['start_time'])->toBe('10:00'); 251 | expect($slots[0]['end_time'])->toBe('11:00'); 252 | expect($slots[0]['is_available'])->toBeFalse(); // Should be blocked 253 | 254 | // Request slots 09:00-10:00 and 11:00-12:00 (adjacent) 255 | $beforeSlots = $user->getAvailableSlots('2025-03-15', '09:00', '10:00', 60); 256 | expect($beforeSlots[0]['is_available'])->toBeTrue(); // Should be available 257 | 258 | $afterSlots = $user->getAvailableSlots('2025-03-15', '11:00', '12:00', 60); 259 | expect($afterSlots[0]['is_available'])->toBeTrue(); // Should be available 260 | }); 261 | 262 | it('finds gaps between adjacent schedules', function () { 263 | $user = createUser(); 264 | 265 | // Two adjacent schedules with gap 266 | Zap::for($user) 267 | ->from('2025-03-15') 268 | ->addPeriod('09:00', '10:00') // Morning 269 | ->addPeriod('10:30', '11:30') // Late morning 270 | ->save(); 271 | 272 | // Look for 30-minute slot - should find the gap 273 | $nextSlot = $user->getNextAvailableSlot('2025-03-15', 30, '09:00', '12:00'); 274 | expect($nextSlot['start_time'])->toBe('10:00'); 275 | expect($nextSlot['end_time'])->toBe('10:30'); 276 | 277 | // Look for 45-minute slot - gap is too small, should find either at very beginning or after 11:30 278 | $nextSlot45 = $user->getNextAvailableSlot('2025-03-15', 45, '08:00', '12:00'); 279 | expect($nextSlot45['start_time'])->toBe('08:00'); // Should find at the very beginning before any blocks 280 | expect($nextSlot45['end_time'])->toBe('08:45'); 281 | }); 282 | 283 | }); 284 | 285 | describe('Performance with complex schedules', function () { 286 | 287 | it('performs well with many recurring schedules', function () { 288 | $user = createUser(); 289 | 290 | // Create 10 different recurring schedules 291 | for ($i = 0; $i < 10; $i++) { 292 | $startHour = 9 + $i; 293 | $endHour = $startHour + 1; 294 | 295 | Zap::for($user) 296 | ->named("Schedule {$i}") 297 | ->from('2025-03-15') 298 | ->addPeriod(sprintf('%02d:00', $startHour), sprintf('%02d:00', $endHour)) 299 | ->weekly(['monday', 'wednesday', 'friday']) 300 | ->save(); 301 | } 302 | 303 | $startTime = microtime(true); 304 | 305 | $slots = $user->getAvailableSlots('2025-03-17', '08:00', '20:00', 60); // Monday 306 | $nextSlot = $user->getNextAvailableSlot('2025-03-17', 120, '08:00', '20:00'); 307 | 308 | $executionTime = microtime(true) - $startTime; 309 | 310 | expect($executionTime)->toBeLessThan(0.2); // Should complete quickly 311 | expect($slots)->toBeArray(); 312 | expect(count($slots))->toBeGreaterThan(0); 313 | }); 314 | 315 | }); 316 | 317 | }); 318 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | |-------------------------------------------------------------------------- 5 | | Test Case 6 | |-------------------------------------------------------------------------- 7 | | 8 | | The closure you provide to your test functions is always bound to a specific PHPUnit test 9 | | case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may 10 | | need to change it using the "uses()" function to bind a different classes or traits. 11 | | 12 | */ 13 | 14 | uses(Zap\Tests\TestCase::class)->in('Feature', 'Unit'); 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Expectations 19 | |-------------------------------------------------------------------------- 20 | | 21 | | When you're writing tests, you often need to check that values meet certain conditions. The 22 | | "expect()" function gives you access to a set of "expectations" methods that you can use 23 | | to assert different things. Of course, you may extend the Expectation API at any time. 24 | | 25 | */ 26 | 27 | expect()->extend('toBeOne', function () { 28 | return $this->toBe(1); 29 | }); 30 | 31 | expect()->extend('toHaveSchedule', function () { 32 | return $this->toHaveProperty('schedules'); 33 | }); 34 | 35 | expect()->extend('toBeSchedulable', function () { 36 | return $this->toHaveMethod('schedules'); 37 | }); 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Functions 42 | |-------------------------------------------------------------------------- 43 | | 44 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 45 | | project that you don't want to repeat in every file. Here you can also expose helpers as 46 | | global functions to help you to reduce the number of lines of code in your test files. 47 | | 48 | */ 49 | 50 | function createUser() 51 | { 52 | static $instance = null; 53 | 54 | if ($instance === null) { 55 | $instance = new class extends \Illuminate\Database\Eloquent\Model 56 | { 57 | use \Zap\Models\Concerns\HasSchedules; 58 | 59 | protected $table = 'users'; 60 | 61 | protected $fillable = ['name', 'email']; 62 | 63 | public function getKey() 64 | { 65 | return 1; // Mock user ID 66 | } 67 | }; 68 | } 69 | 70 | return $instance; 71 | } 72 | 73 | function createRoom() 74 | { 75 | static $instance = null; 76 | 77 | if ($instance === null) { 78 | $instance = new class extends \Illuminate\Database\Eloquent\Model 79 | { 80 | use \Zap\Models\Concerns\HasSchedules; 81 | 82 | protected $table = 'rooms'; 83 | 84 | protected $fillable = ['name', 'capacity']; 85 | 86 | public function getKey() 87 | { 88 | return 1; // Mock room ID 89 | } 90 | }; 91 | } 92 | 93 | return $instance; 94 | } 95 | 96 | function createScheduleFor($schedulable, array $attributes = []) 97 | { 98 | $attributes = array_merge([ 99 | 'name' => 'Test Schedule', 100 | 'start_date' => '2024-01-01', 101 | 'periods' => [ 102 | ['start_time' => '09:00', 'end_time' => '10:00'], 103 | ], 104 | ], $attributes); 105 | 106 | $builder = \Zap\Facades\Zap::for($schedulable) 107 | ->named($attributes['name']) 108 | ->from($attributes['start_date']); 109 | 110 | if (isset($attributes['end_date'])) { 111 | $builder->to($attributes['end_date']); 112 | } 113 | 114 | foreach ($attributes['periods'] as $period) { 115 | $builder->addPeriod($period['start_time'], $period['end_time']); 116 | } 117 | 118 | return $builder->save(); 119 | } 120 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace Zap\Tests; 4 | 5 | use Illuminate\Foundation\Testing\RefreshDatabase; 6 | use Orchestra\Testbench\TestCase as Orchestra; 7 | use Zap\ZapServiceProvider; 8 | 9 | abstract class TestCase extends Orchestra 10 | { 11 | use RefreshDatabase; 12 | 13 | protected function setUp(): void 14 | { 15 | parent::setUp(); 16 | 17 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 18 | } 19 | 20 | protected function getPackageProviders($app): array 21 | { 22 | return [ 23 | ZapServiceProvider::class, 24 | ]; 25 | } 26 | 27 | protected function getEnvironmentSetUp($app): void 28 | { 29 | config()->set('database.default', 'testing'); 30 | config()->set('database.connections.testing', [ 31 | 'driver' => 'sqlite', 32 | 'database' => ':memory:', 33 | 'prefix' => '', 34 | ]); 35 | 36 | // Load package configuration with test-friendly defaults 37 | $app['config']->set('zap', [ 38 | 'conflict_detection' => [ 39 | 'enabled' => true, 40 | 'buffer_minutes' => 0, 41 | ], 42 | 'validation' => [ 43 | 'require_future_dates' => false, 44 | 'max_date_range' => 3650, 45 | 'min_period_duration' => 1, 46 | 'max_period_duration' => 1440, 47 | 'max_periods_per_schedule' => 100, 48 | 'allow_overlapping_periods' => true, 49 | ], 50 | 'default_rules' => [ 51 | 'no_overlap' => [ 52 | 'enabled' => true, 53 | 'applies_to' => ['appointment', 'blocked'], 54 | ], 55 | 'working_hours' => [ 56 | 'enabled' => false, 57 | 'start' => '09:00', 58 | 'end' => '17:00', 59 | ], 60 | 'max_duration' => [ 61 | 'enabled' => false, 62 | 'minutes' => 480, 63 | ], 64 | 'no_weekends' => [ 65 | 'enabled' => false, 66 | 'saturday' => true, 67 | 'sunday' => true, 68 | ], 69 | ], 70 | 'cache' => [ 71 | 'enabled' => false, 72 | ], 73 | ]); 74 | } 75 | 76 | protected function defineDatabaseMigrations(): void 77 | { 78 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/Unit/ScheduleBuilderTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Zap\Builders\ScheduleBuilder; 4 | use Zap\Models\Schedule; 5 | 6 | describe('ScheduleBuilder', function () { 7 | 8 | it('can build schedule attributes correctly', function () { 9 | $user = createUser(); 10 | 11 | $builder = new ScheduleBuilder; 12 | $built = $builder 13 | ->for($user) 14 | ->named('Test Meeting') 15 | ->description('A test meeting') 16 | ->from('2025-01-01') 17 | ->to('2025-12-31') 18 | ->addPeriod('09:00', '10:00') 19 | ->weekly(['monday']) 20 | ->withMetadata(['room' => 'A']) 21 | ->build(); 22 | 23 | expect($built['attributes'])->toHaveKey('name', 'Test Meeting'); 24 | expect($built['attributes'])->toHaveKey('description', 'A test meeting'); 25 | expect($built['attributes'])->toHaveKey('start_date', '2025-01-01'); 26 | expect($built['attributes'])->toHaveKey('end_date', '2025-12-31'); 27 | expect($built['attributes'])->toHaveKey('is_recurring', true); 28 | expect($built['attributes'])->toHaveKey('frequency', 'weekly'); 29 | expect($built['periods'])->toHaveCount(1); 30 | expect($built['periods'][0])->toMatchArray([ 31 | 'start_time' => '09:00', 32 | 'end_time' => '10:00', 33 | 'date' => '2025-01-01', 34 | ]); 35 | }); 36 | 37 | it('can add multiple periods', function () { 38 | $user = createUser(); 39 | 40 | $builder = new ScheduleBuilder; 41 | $built = $builder 42 | ->for($user) 43 | ->from('2025-01-01') 44 | ->addPeriod('09:00', '10:00') 45 | ->addPeriod('14:00', '15:00') 46 | ->addPeriods([ 47 | ['start_time' => '16:00', 'end_time' => '17:00'], 48 | ]) 49 | ->build(); 50 | 51 | expect($built['periods'])->toHaveCount(3); 52 | expect($built['periods'][0]['start_time'])->toBe('09:00'); 53 | expect($built['periods'][1]['start_time'])->toBe('14:00'); 54 | expect($built['periods'][2]['start_time'])->toBe('16:00'); 55 | }); 56 | 57 | it('can set different recurring frequencies', function () { 58 | $user = createUser(); 59 | 60 | $builder = new ScheduleBuilder; 61 | 62 | // Test daily 63 | $daily = $builder->for($user)->from('2025-01-01')->daily()->build(); 64 | expect($daily['attributes']['frequency'])->toBe('daily'); 65 | 66 | // Test weekly 67 | $builder->reset(); 68 | $weekly = $builder->for($user)->from('2025-01-01')->weekly(['monday', 'friday'])->build(); 69 | expect($weekly['attributes']['frequency'])->toBe('weekly'); 70 | expect($weekly['attributes']['frequency_config'])->toBe(['days' => ['monday', 'friday']]); 71 | 72 | // Test monthly 73 | $builder->reset(); 74 | $monthly = $builder->for($user)->from('2025-01-01')->monthly(['day_of_month' => 15])->build(); 75 | expect($monthly['attributes']['frequency'])->toBe('monthly'); 76 | expect($monthly['attributes']['frequency_config'])->toBe(['day_of_month' => 15]); 77 | }); 78 | 79 | it('can add validation rules', function () { 80 | $user = createUser(); 81 | 82 | $builder = new ScheduleBuilder; 83 | $built = $builder 84 | ->for($user) 85 | ->from('2025-01-01') 86 | ->noOverlap() 87 | ->workingHoursOnly('09:00', '17:00') 88 | ->maxDuration(480) 89 | ->noWeekends() 90 | ->withRule('custom_rule', ['param' => 'value']) 91 | ->build(); 92 | 93 | expect($built['rules'])->toHaveKey('no_overlap'); 94 | expect($built['rules'])->toHaveKey('working_hours'); 95 | expect($built['rules']['working_hours'])->toBe(['start' => '09:00', 'end' => '17:00']); 96 | expect($built['rules'])->toHaveKey('max_duration'); 97 | expect($built['rules']['max_duration'])->toBe(['minutes' => 480]); 98 | expect($built['rules'])->toHaveKey('no_weekends'); 99 | expect($built['rules'])->toHaveKey('custom_rule'); 100 | expect($built['rules']['custom_rule'])->toBe(['param' => 'value']); 101 | }); 102 | 103 | it('can handle metadata', function () { 104 | $user = createUser(); 105 | 106 | $builder = new ScheduleBuilder; 107 | $built = $builder 108 | ->for($user) 109 | ->from('2025-01-01') 110 | ->withMetadata(['location' => 'Room A']) 111 | ->withMetadata(['priority' => 'high']) // Should merge 112 | ->build(); 113 | 114 | expect($built['attributes']['metadata'])->toBe([ 115 | 'location' => 'Room A', 116 | 'priority' => 'high', 117 | ]); 118 | }); 119 | 120 | it('can set active/inactive status', function () { 121 | $user = createUser(); 122 | 123 | $builder = new ScheduleBuilder; 124 | 125 | // Test active (default) 126 | $active = $builder->for($user)->from('2025-01-01')->active()->build(); 127 | expect($active['attributes']['is_active'])->toBe(true); 128 | 129 | // Test inactive 130 | $builder->reset(); 131 | $inactive = $builder->for($user)->from('2025-01-01')->inactive()->build(); 132 | expect($inactive['attributes']['is_active'])->toBe(false); 133 | }); 134 | 135 | it('can clone builder with same configuration', function () { 136 | $user = createUser(); 137 | 138 | $builder = new ScheduleBuilder; 139 | $builder 140 | ->for($user) 141 | ->named('Original') 142 | ->from('2025-01-01') 143 | ->addPeriod('09:00', '10:00') 144 | ->weekly(['monday']); 145 | 146 | $clone = $builder->clone(); 147 | $clone->named('Cloned'); 148 | 149 | $original = $builder->build(); 150 | $cloned = $clone->build(); 151 | 152 | expect($original['attributes']['name'])->toBe('Original'); 153 | expect($cloned['attributes']['name'])->toBe('Cloned'); 154 | expect($original['attributes']['start_date'])->toBe($cloned['attributes']['start_date']); 155 | expect($original['periods'])->toEqual($cloned['periods']); 156 | }); 157 | 158 | it('validates required fields', function () { 159 | $builder = new ScheduleBuilder; 160 | 161 | // Missing schedulable 162 | expect(fn () => $builder->from('2025-01-01')->build()) 163 | ->toThrow(\InvalidArgumentException::class, 'Schedulable model must be set'); 164 | 165 | // Missing start date 166 | $user = createUser(); 167 | $builder->reset(); // Reset builder state 168 | expect(fn () => $builder->for($user)->build()) 169 | ->toThrow(\InvalidArgumentException::class, 'Start date must be set'); 170 | }); 171 | 172 | it('can use between method for date range', function () { 173 | $user = createUser(); 174 | 175 | $builder = new ScheduleBuilder; 176 | $built = $builder 177 | ->for($user) 178 | ->between('2025-01-01', '2025-12-31') 179 | ->build(); 180 | 181 | expect($built['attributes']['start_date'])->toBe('2025-01-01'); 182 | expect($built['attributes']['end_date'])->toBe('2025-12-31'); 183 | }); 184 | 185 | it('provides getter methods for current state', function () { 186 | $user = createUser(); 187 | 188 | $builder = new ScheduleBuilder; 189 | $builder 190 | ->for($user) 191 | ->named('Test') 192 | ->from('2025-01-01') 193 | ->addPeriod('09:00', '10:00') 194 | ->noOverlap(); 195 | 196 | expect($builder->getAttributes())->toHaveKey('name', 'Test'); 197 | expect($builder->getPeriods())->toHaveCount(1); 198 | expect($builder->getRules())->toHaveKey('no_overlap'); 199 | }); 200 | 201 | }); 202 | 203 | describe('ScheduleBuilder Integration', function () { 204 | 205 | it('integrates with ScheduleService for saving', function () { 206 | $user = createUser(); 207 | 208 | $schedule = (new ScheduleBuilder) 209 | ->for($user) 210 | ->named('Integration Test') 211 | ->from('2025-01-01') 212 | ->addPeriod('09:00', '10:00') 213 | ->save(); 214 | 215 | expect($schedule)->toBeInstanceOf(Schedule::class); 216 | expect($schedule->name)->toBe('Integration Test'); 217 | }); 218 | 219 | }); 220 | -------------------------------------------------------------------------------- /tests/phpstan/Users.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | class Users extends \Illuminate\Database\Eloquent\Model 4 | { 5 | use \Zap\Models\Concerns\HasSchedules; 6 | 7 | protected $table = 'users'; 8 | 9 | protected $fillable = ['name', 'email']; 10 | 11 | public function getKey() 12 | { 13 | return 1; 14 | } 15 | } 16 | --------------------------------------------------------------------------------