Skip to content

Commit 8258a49

Browse files
authored
Performance improvements - skipping already finished events (#177)
1 parent 579d535 commit 8258a49

2 files changed

Lines changed: 225 additions & 5 deletions

File tree

‎src/Events.php‎

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace TransformStudios\Events;
44

5+
use Carbon\CarbonImmutable;
56
use Carbon\CarbonInterface;
67
use Exception;
78
use Illuminate\Pagination\Paginator;
@@ -181,20 +182,22 @@ public function timezone(string $timezone): self
181182
public function between(string|CarbonInterface $from, string|CarbonInterface $to): EntryCollection|LengthAwarePaginator
182183
{
183184
return $this->output(
184-
type: fn (Entry $entry) => EventFactory::createFromEntry(event: $entry, collapseMultiDays: $this->collapseMultiDays)->occurrencesBetween(from: $from, to: $to)
185+
type: fn (Entry $entry) => EventFactory::createFromEntry(event: $entry, collapseMultiDays: $this->collapseMultiDays)->occurrencesBetween(from: $from, to: $to),
186+
from: $from
185187
);
186188
}
187189

188190
public function upcoming(int $limit = 1): EntryCollection|LengthAwarePaginator
189191
{
190192
return $this->output(
191-
type: fn (Entry $entry) => EventFactory::createFromEntry(event: $entry, collapseMultiDays: $this->collapseMultiDays)->nextOccurrences(limit: $limit)
193+
type: fn (Entry $entry) => EventFactory::createFromEntry(event: $entry, collapseMultiDays: $this->collapseMultiDays)->nextOccurrences(limit: $limit),
194+
from: now()
192195
);
193196
}
194197

195-
private function output(callable $type): EntryCollection|LengthAwarePaginator
198+
private function output(callable $type, string|CarbonInterface $from): EntryCollection|LengthAwarePaginator
196199
{
197-
$occurrences = $this->entries()->occurrences(generator: $type);
200+
$occurrences = $this->entries()->occurrences(generator: $type, from: $from);
198201

199202
if (! is_null($this->timezone)) {
200203
$occurrences->transform(function (Entry $occurrence) {
@@ -227,9 +230,10 @@ private function isMultiDay(Entry $occurrence): bool
227230
return EventFactory::getTypeClass(event: $occurrence) === MultiDayEvent::class;
228231
}
229232

230-
private function occurrences(callable $generator): EntryCollection
233+
private function occurrences(callable $generator, string|CarbonInterface $from): EntryCollection
231234
{
232235
return $this->entries
236+
->reject(fn (Entry $event) => $this->hasEnded(event: $event, from: $from))
233237
->filter(fn (Entry $event) => $this->hasStartDate($event))
234238
// take each event and generate the occurrences
235239
->flatMap(callback: $generator)
@@ -240,6 +244,56 @@ private function occurrences(callable $generator): EntryCollection
240244
->values();
241245
}
242246

247+
/*
248+
Generating occurrences means augmenting, which is expensive, every entry. Most
249+
collections are mostly made up of events that finished long ago, so skip those up
250+
front using only raw values
251+
*/
252+
private function hasEnded(Entry $event, string|CarbonInterface $from): bool
253+
{
254+
if (is_null($lastDate = $this->lastPossibleDate($event))) {
255+
return false;
256+
}
257+
258+
$from = is_string($from) ? CarbonImmutable::parse($from) : $from->toImmutable();
259+
260+
// an event's last day runs until midnight in its own timezone, which we don't
261+
// know without augmenting, so leave a couple of days of slack to cover any offset
262+
return $lastDate->lt($from->subDays(2)->startOfDay());
263+
}
264+
265+
private function lastPossibleDate(Entry $event): ?CarbonImmutable
266+
{
267+
$recurrence = $event->get('recurrence');
268+
269+
if ($recurrence === 'multi_day' || $event->get('multi_day')) {
270+
$dates = collect($event->get('days'))->pluck('date')->filter();
271+
272+
return $dates->isEmpty() ? null : $this->parseDate($dates->max());
273+
}
274+
275+
if (in_array($recurrence, ['daily', 'weekly', 'monthly', 'every'])) {
276+
// no end date means it recurs forever
277+
return $this->parseDate($event->get('end_date'));
278+
}
279+
280+
return $this->parseDate($event->get('start_date'));
281+
}
282+
283+
// a date we can't read is one we can't rule out, so let it through to the generator
284+
private function parseDate($date): ?CarbonImmutable
285+
{
286+
if (blank($date)) {
287+
return null;
288+
}
289+
290+
try {
291+
return CarbonImmutable::parse($date);
292+
} catch (Exception $e) {
293+
return null;
294+
}
295+
}
296+
243297
private function hasStartDate(Entry $occurrence): bool
244298
{
245299
if ($this->isMultiDay($occurrence)) {

‎tests/SkipsEndedEventsTest.php‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
namespace TransformStudios\Events\Tests;
4+
5+
use Illuminate\Support\Carbon;
6+
use Statamic\Facades\Entry;
7+
use TransformStudios\Events\Events;
8+
9+
beforeEach(function () {
10+
Carbon::setTestNow(now()->setTimeFromTimeString('10:00'));
11+
});
12+
13+
test('keeps unbounded recurring event that started years ago', function () {
14+
Entry::make()
15+
->collection('events')
16+
->slug('weekly-halaqa')
17+
->data([
18+
'title' => 'Weekly Halaqa',
19+
'start_date' => now()->subYears(2)->toDateString(),
20+
'start_time' => '19:00',
21+
'end_time' => '20:00',
22+
'recurrence' => 'weekly',
23+
])->save();
24+
25+
expect(Events::fromCollection('events')->upcoming(1))->toHaveCount(1);
26+
});
27+
28+
test('keeps annually recurring event that started years ago', function () {
29+
Entry::make()
30+
->collection('events')
31+
->slug('annual-fundraiser')
32+
->data([
33+
'title' => 'Annual Fundraiser',
34+
'start_date' => now()->subYears(3)->toDateString(),
35+
'start_time' => '18:00',
36+
'end_time' => '21:00',
37+
'recurrence' => 'every',
38+
'interval' => 1,
39+
'period' => 'years',
40+
])->save();
41+
42+
expect(Events::fromCollection('events')->upcoming(1))->toHaveCount(1);
43+
});
44+
45+
test('keeps recurring event whose end date is still in the future', function () {
46+
Entry::make()
47+
->collection('events')
48+
->slug('term-class')
49+
->data([
50+
'title' => 'Term Class',
51+
'start_date' => now()->subYear()->toDateString(),
52+
'start_time' => '09:00',
53+
'end_time' => '10:00',
54+
'recurrence' => 'weekly',
55+
'end_date' => now()->addMonths(2)->toDateString(),
56+
])->save();
57+
58+
expect(Events::fromCollection('events')->upcoming(1))->toHaveCount(1);
59+
});
60+
61+
test('drops recurring event whose end date has passed', function () {
62+
Entry::make()
63+
->collection('events')
64+
->slug('finished-class')
65+
->data([
66+
'title' => 'Finished Class',
67+
'start_date' => now()->subYears(2)->toDateString(),
68+
'start_time' => '09:00',
69+
'end_time' => '10:00',
70+
'recurrence' => 'weekly',
71+
'end_date' => now()->subYear()->toDateString(),
72+
])->save();
73+
74+
expect(Events::fromCollection('events')->upcoming(1))->toBeEmpty();
75+
});
76+
77+
test('drops single day event in the past but keeps one in the future', function () {
78+
Entry::make()
79+
->collection('events')
80+
->slug('past-lecture')
81+
->data([
82+
'title' => 'Past Lecture',
83+
'start_date' => now()->subMonth()->toDateString(),
84+
'start_time' => '19:00',
85+
])->save();
86+
87+
Entry::make()
88+
->collection('events')
89+
->slug('future-lecture')
90+
->data([
91+
'title' => 'Future Lecture',
92+
'start_date' => now()->addMonth()->toDateString(),
93+
'start_time' => '19:00',
94+
])->save();
95+
96+
$occurrences = Events::fromCollection('events')->upcoming(1);
97+
98+
expect($occurrences)->toHaveCount(1);
99+
expect($occurrences->first()->title)->toBe('Future Lecture');
100+
});
101+
102+
test('drops multi day event whose days have all passed but keeps one with a day still to come', function () {
103+
Entry::make()
104+
->collection('events')
105+
->slug('past-conference')
106+
->data([
107+
'title' => 'Past Conference',
108+
'recurrence' => 'multi_day',
109+
'days' => [
110+
['date' => now()->subMonth()->toDateString(), 'start_time' => '09:00', 'end_time' => '17:00'],
111+
['date' => now()->subMonth()->addDay()->toDateString(), 'start_time' => '09:00', 'end_time' => '17:00'],
112+
],
113+
])->save();
114+
115+
Entry::make()
116+
->collection('events')
117+
->slug('upcoming-conference')
118+
->data([
119+
'title' => 'Upcoming Conference',
120+
'recurrence' => 'multi_day',
121+
'days' => [
122+
['date' => now()->addMonth()->toDateString(), 'start_time' => '09:00', 'end_time' => '17:00'],
123+
['date' => now()->addMonth()->addDay()->toDateString(), 'start_time' => '09:00', 'end_time' => '17:00'],
124+
],
125+
])->save();
126+
127+
$occurrences = Events::fromCollection('events')->upcoming(5);
128+
129+
expect($occurrences)->not->toBeEmpty();
130+
expect($occurrences->pluck('title')->unique()->all())->toBe(['Upcoming Conference']);
131+
});
132+
133+
test('keeps a multi day event that is partway through', function () {
134+
Entry::make()
135+
->collection('events')
136+
->slug('current-conference')
137+
->data([
138+
'title' => 'Current Conference',
139+
'recurrence' => 'multi_day',
140+
'days' => [
141+
['date' => now()->subDay()->toDateString(), 'start_time' => '09:00', 'end_time' => '17:00'],
142+
['date' => now()->addDay()->toDateString(), 'start_time' => '09:00', 'end_time' => '17:00'],
143+
],
144+
])->save();
145+
146+
expect(Events::fromCollection('events')->upcoming(5))->not->toBeEmpty();
147+
});
148+
149+
test('past events are still returned when explicitly querying a past range', function () {
150+
Entry::make()
151+
->collection('events')
152+
->slug('last-years-lecture')
153+
->data([
154+
'title' => 'Last Years Lecture',
155+
'start_date' => now()->subYear()->toDateString(),
156+
'start_time' => '19:00',
157+
'end_time' => '20:00',
158+
])->save();
159+
160+
$occurrences = Events::fromCollection('events')->between(
161+
now()->subYear()->startOfDay(),
162+
now()->subYear()->endOfDay(),
163+
);
164+
165+
expect($occurrences)->toHaveCount(1);
166+
});

0 commit comments

Comments
 (0)