The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | 


--------------------------------------------------------------------------------