Skip to content

Commit d1cfc14

Browse files
committed
feat: add performance optimizations for calendar event lookups
1 parent 35c0b99 commit d1cfc14

File tree

10 files changed

+881
-10
lines changed

10 files changed

+881
-10
lines changed

Config/config.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,19 @@
33
return [
44
'name' => 'Calendar',
55
'calendar_list' => env( 'CALENDAR_LIST', '' ),
6+
7+
// Performance optimization feature flags
8+
'performance' => [
9+
// Enable optimized event lookup using database index
10+
'enable_event_index' => env('CALENDAR_ENABLE_EVENT_INDEX', false),
11+
12+
// Enable CalDAV REPORT queries for single event fetching
13+
'enable_caldav_reports' => env('CALENDAR_ENABLE_CALDAV_REPORTS', false),
14+
15+
// Force legacy mode for all calendars (emergency override)
16+
'force_legacy_mode' => env('CALENDAR_FORCE_LEGACY_MODE', false),
17+
18+
// Enable performance metrics tracking
19+
'enable_metrics' => env('CALENDAR_ENABLE_METRICS', false),
20+
],
621
];
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
use Illuminate\Support\Facades\Schema;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Database\Migrations\Migration;
6+
7+
class CreateCalendarEventIndexTable extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*
12+
* @return void
13+
*/
14+
public function up()
15+
{
16+
Schema::create('calendar_event_index', function (Blueprint $table) {
17+
$table->bigIncrements('id');
18+
19+
// Calendar relationship
20+
$table->unsignedInteger('calendar_id');
21+
22+
// Event identification
23+
$table->string('event_uid', 255);
24+
$table->string('event_summary', 500)->nullable();
25+
26+
// Event timing (stored in UTC)
27+
$table->dateTime('event_start')->nullable();
28+
$table->dateTime('event_end')->nullable();
29+
$table->boolean('is_all_day')->default(false);
30+
31+
// Event data storage
32+
$table->json('event_data'); // Full event data as JSON
33+
$table->string('event_location', 255)->nullable();
34+
35+
// Sync tracking
36+
$table->unsignedInteger('sync_version')->default(1);
37+
$table->timestamp('last_synced_at')->nullable();
38+
39+
// Calendar-specific flags
40+
$table->boolean('is_recurring')->default(false);
41+
$table->string('recurrence_id', 255)->nullable(); // For recurring event instances
42+
43+
// Performance and tracking
44+
$table->unsignedInteger('access_count')->default(0); // Track popular events
45+
$table->timestamp('last_accessed_at')->nullable();
46+
47+
// Laravel timestamps
48+
$table->timestamps();
49+
50+
// Indexes for performance
51+
$table->index(['calendar_id', 'event_uid'], 'idx_calendar_event');
52+
$table->index('event_uid', 'idx_event_uid');
53+
$table->index(['event_start', 'event_end'], 'idx_event_dates');
54+
$table->index('sync_version', 'idx_sync_version');
55+
$table->index(['calendar_id', 'last_synced_at'], 'idx_calendar_sync');
56+
57+
// Foreign key constraint
58+
$table->foreign('calendar_id')
59+
->references('id')
60+
->on('calendars')
61+
->onDelete('cascade');
62+
});
63+
}
64+
65+
/**
66+
* Reverse the migrations.
67+
*
68+
* @return void
69+
*/
70+
public function down()
71+
{
72+
Schema::dropIfExists('calendar_event_index');
73+
}
74+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
use Illuminate\Support\Facades\Schema;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Database\Migrations\Migration;
6+
7+
class AddSyncVersionToCalendarsTable extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*
12+
* @return void
13+
*/
14+
public function up()
15+
{
16+
Schema::table('calendars', function (Blueprint $table) {
17+
// Track sync version for change detection
18+
$table->unsignedInteger('sync_version')->default(0)->after('custom_fields');
19+
20+
// Track last sync time for cache invalidation
21+
$table->timestamp('last_full_sync')->nullable()->after('sync_version');
22+
23+
// Add force_legacy_mode per calendar
24+
$table->boolean('force_legacy_mode')->default(false)->after('last_full_sync');
25+
26+
// Add index for sync tracking
27+
$table->index('sync_version');
28+
});
29+
}
30+
31+
/**
32+
* Reverse the migrations.
33+
*
34+
* @return void
35+
*/
36+
public function down()
37+
{
38+
Schema::table('calendars', function (Blueprint $table) {
39+
$table->dropIndex(['sync_version']);
40+
$table->dropColumn(['sync_version', 'last_full_sync', 'force_legacy_mode']);
41+
});
42+
}
43+
}

Entities/Calendar.php

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,17 @@ class Calendar extends Model implements JsonSerializable {
4242
'permissions',
4343
'custom_fields',
4444
'title_template',
45+
'sync_version',
46+
'last_full_sync',
47+
'force_legacy_mode',
4548
];
4649

4750
protected $casts = [
4851
'enabled' => 'boolean',
4952
'permissions' => 'array',
5053
'custom_fields' => 'array',
54+
'force_legacy_mode' => 'boolean',
55+
'last_full_sync' => 'datetime',
5156
];
5257

5358
/**
@@ -247,11 +252,84 @@ private function getICSAsCalendarItems( int $daysBeforeToday, int $daysAfterToda
247252
* @throws Exception
248253
*/
249254
public function findEventById( string $eventId ): ?array {
255+
// Check if we should use legacy mode
256+
$forceLegacy = config('ljpccalendarmodule.performance.force_legacy_mode', false) || $this->force_legacy_mode;
257+
$enableEventIndex = config('ljpccalendarmodule.performance.enable_event_index', false);
258+
$enableCalDAVReports = config('ljpccalendarmodule.performance.enable_caldav_reports', false);
259+
250260
if ( $this->type === 'normal' ) {
251261
// This case is already handled efficiently in the controller
252262
return null;
253263
}
254264

265+
// Step 1: Try database index if enabled and not in legacy mode
266+
if ( !$forceLegacy && $enableEventIndex ) {
267+
try {
268+
$startTime = microtime(true);
269+
270+
$indexedEvent = CalendarEventIndex::where('calendar_id', $this->id)
271+
->where('event_uid', $eventId)
272+
->first();
273+
274+
if ( $indexedEvent ) {
275+
// Check if the data is fresh based on refresh interval
276+
$refreshString = $this->custom_fields['refresh'] ?? '1 hour';
277+
$refreshInterval = $this->getRefreshIntervalInSeconds($refreshString);
278+
if ( $indexedEvent->isFresh($refreshInterval) ) {
279+
$indexedEvent->recordAccess();
280+
281+
// Log performance metrics
282+
if ( config('ljpccalendarmodule.performance.enable_metrics', false) ) {
283+
Log::info('Calendar event found in index', [
284+
'calendar_id' => $this->id,
285+
'event_uid' => $eventId,
286+
'lookup_time_ms' => round((microtime(true) - $startTime) * 1000, 2),
287+
'cache_hit' => true
288+
]);
289+
}
290+
291+
return $indexedEvent->toCalendarItemArray();
292+
}
293+
}
294+
} catch ( \Exception $e ) {
295+
Log::error('Error accessing event index', [
296+
'calendar_id' => $this->id,
297+
'event_uid' => $eventId,
298+
'error' => $e->getMessage()
299+
]);
300+
// Continue to other methods
301+
}
302+
}
303+
304+
// Step 2: For CalDAV, try REPORT query if enabled
305+
if ( !$forceLegacy && $this->type === 'caldav' && $enableCalDAVReports ) {
306+
try {
307+
$eventData = $this->fetchCalDAVEventByUid( $eventId );
308+
if ( $eventData !== null ) {
309+
// Update index if enabled
310+
if ( $enableEventIndex ) {
311+
try {
312+
CalendarEventIndex::updateFromEventData( $this->id, $eventData, $this->sync_version );
313+
} catch ( \Exception $e ) {
314+
Log::warning('Failed to update event index after CalDAV fetch', [
315+
'calendar_id' => $this->id,
316+
'event_uid' => $eventId,
317+
'error' => $e->getMessage()
318+
]);
319+
}
320+
}
321+
322+
return $eventData;
323+
}
324+
} catch ( \Exception $e ) {
325+
Log::warning('CalDAV REPORT query failed, falling back to full fetch', [
326+
'calendar_id' => $this->id,
327+
'event_uid' => $eventId,
328+
'error' => $e->getMessage()
329+
]);
330+
}
331+
}
332+
255333
if ( $this->type === 'ics' || $this->type === 'caldav' ) {
256334
// Try to get the event from cache first
257335
$eventCacheKey = 'calendar_event_' . $this->id . '_' . md5( $eventId );
@@ -434,5 +512,141 @@ public function findEventById( string $eventId ): ?array {
434512
return null;
435513
}
436514

515+
/**
516+
* Fetch a single CalDAV event by UID using REPORT query
517+
*
518+
* @param string $eventId The event UID
519+
* @return array|null The event data or null if not found
520+
*/
521+
protected function fetchCalDAVEventByUid( string $eventId ): ?array {
522+
$fullUrl = $this->custom_fields['url'];
523+
$baseUrl = substr( $fullUrl, 0, strpos( $fullUrl, '/', 8 ) );
524+
$remainingUrl = substr( $fullUrl, strpos( $fullUrl, '/', 8 ) );
525+
526+
$caldavClient = new CalDAV( $baseUrl, $this->custom_fields['username'], $this->custom_fields['password'] );
527+
528+
// Check if server supports REPORT queries
529+
if ( !$caldavClient->supportsReportQuery( $remainingUrl ) ) {
530+
Log::info('CalDAV server does not support REPORT queries', [
531+
'calendar_id' => $this->id,
532+
'url' => $fullUrl
533+
]);
534+
return null;
535+
}
536+
537+
// Try to fetch the specific event
538+
$icsData = $caldavClient->getEventByUid( $remainingUrl, $eventId );
539+
540+
if ( $icsData === null ) {
541+
return null;
542+
}
543+
544+
// Parse the ICS data
545+
try {
546+
$ical = new ICal();
547+
$ical->initString( $icsData );
548+
549+
$events = $ical->events();
550+
if ( empty( $events ) ) {
551+
return null;
552+
}
553+
554+
// Process the first (and should be only) event
555+
$event = $events[0];
556+
557+
$start = DateTimeImmutable::createFromMutable(
558+
$ical->iCalDateToDateTime( $event->dtstart_array[3] )->setTimezone( new DateTimeZone( 'UTC' ) )
559+
);
560+
561+
if ( ! empty( $event->dtend ) ) {
562+
$end = DateTimeImmutable::createFromMutable(
563+
$ical->iCalDateToDateTime( $event->dtend_array[3] )->setTimezone( new DateTimeZone( 'UTC' ) )
564+
);
565+
} else {
566+
if ( ! empty( $event->duration ) ) {
567+
$end = $start->add( new DateInterval( $event->duration ) );
568+
} else {
569+
$end = $start->add( new DateInterval( 'PT1H' ) );
570+
}
571+
}
572+
573+
$modifiedEnd = $end;
574+
if ( DateTimeRange::isAllDay( $start, $end ) ) {
575+
$modifiedEnd = $end->modify( '-1 second' );
576+
}
577+
578+
$body = $event->description;
579+
$customFields = [];
580+
581+
// Parse YAML from description if present
582+
try {
583+
if ( $body !== null ) {
584+
$parsedResult = Yaml::parse( $body, Loader::IGNORE_COMMENTS | Loader::IGNORE_DIRECTIVES | Loader::NO_OBJECT_FOR_DATE );
585+
// Handle both array and object results
586+
if ( is_object( $parsedResult ) && method_exists( $parsedResult, 'jsonSerialize' ) ) {
587+
$parsedYaml = $parsedResult->jsonSerialize();
588+
} else {
589+
$parsedYaml = $parsedResult;
590+
}
591+
if ( is_array( $parsedYaml ) && array_has( $parsedYaml, 'body' ) ) {
592+
$body = $parsedYaml['body'];
593+
if ( isset( $parsedYaml['custom_fields'] ) ) {
594+
$customFields = $parsedYaml['custom_fields'];
595+
}
596+
}
597+
}
598+
} catch ( Exception $e ) {
599+
// Ignore parsing errors
600+
}
601+
602+
// Create CalendarItem-compatible array
603+
return [
604+
'id' => $event->uid,
605+
'uid' => $event->uid,
606+
'calendar_id' => $this->id,
607+
'title' => $event->summary,
608+
'location' => $event->location,
609+
'body' => $body,
610+
'state' => $event->status,
611+
'start' => $start->format( 'Y-m-d H:i:s' ),
612+
'end' => $modifiedEnd->format( 'Y-m-d H:i:s' ),
613+
'is_all_day' => DateTimeRange::isAllDay( $start, $end ),
614+
'is_private' => false,
615+
'is_read_only' => false, // CalDAV events can be edited
616+
'custom_fields' => $customFields,
617+
];
618+
619+
} catch ( \Exception $e ) {
620+
Log::error('Failed to parse CalDAV event data', [
621+
'calendar_id' => $this->id,
622+
'event_uid' => $eventId,
623+
'error' => $e->getMessage()
624+
]);
625+
return null;
626+
}
627+
}
628+
629+
/**
630+
* Convert refresh interval string to seconds
631+
*
632+
* @param string $refreshString
633+
* @return int
634+
*/
635+
protected function getRefreshIntervalInSeconds( string $refreshString ): int {
636+
$intervals = [
637+
'1 minute' => 60,
638+
'5 minutes' => 300,
639+
'15 minutes' => 900,
640+
'30 minutes' => 1800,
641+
'1 hour' => 3600,
642+
'2 hours' => 7200,
643+
'6 hours' => 21600,
644+
'12 hours' => 43200,
645+
'daily' => 86400,
646+
];
647+
648+
return $intervals[$refreshString] ?? 3600; // Default to 1 hour
649+
}
650+
437651

438652
}

0 commit comments

Comments
 (0)