From e73efbd09e13f40a1ce59628131068ebb1ab2189 Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Wed, 2 Sep 2026 12:59:33 -0700 Subject: [PATCH 1/3] Collapse multi-day events to a single occurrence (#198) --- src/Types/MultiDayEvent.php | 19 ++++++++++++++++++ tests/Types/MultiDayEventsTest.php | 31 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/Types/MultiDayEvent.php b/src/Types/MultiDayEvent.php index 9dd2a46..7ed1941 100644 --- a/src/Types/MultiDayEvent.php +++ b/src/Types/MultiDayEvent.php @@ -45,6 +45,16 @@ public function end(): CarbonImmutable return $this->days->last()->end(); } + public function nextOccurrences(int $limit = 1): Collection + { + return $this->uniqueCollapsedOccurrences(parent::nextOccurrences($limit)); + } + + public function occurrencesBetween(string|CarbonInterface $from, string|CarbonInterface $to): Collection + { + return $this->uniqueCollapsedOccurrences(parent::occurrencesBetween($from, $to)); + } + public function start(): CarbonImmutable { return $this->days->first()->start(); @@ -139,4 +149,13 @@ private function getDayFromDate(CarbonInterface $date): ?Day { return $this->days->first(fn (Day $day, int $index) => $this->collapseMultiDays ? $index == 0 : $date->isSameDay($day->start())); } + + private function uniqueCollapsedOccurrences(Collection $occurrences): Collection + { + if (! $this->collapseMultiDays) { + return $occurrences; + } + + return $occurrences->unique(fn (Entry $occurrence) => $occurrence->id())->values(); + } } diff --git a/tests/Types/MultiDayEventsTest.php b/tests/Types/MultiDayEventsTest.php index c7f4ea3..e277730 100755 --- a/tests/Types/MultiDayEventsTest.php +++ b/tests/Types/MultiDayEventsTest.php @@ -36,6 +36,7 @@ 'timezone' => 'America/Vancouver', ]); + $this->entry = $entry; $this->event = EventFactory::createFromEntry($entry); $noEndTimeEntry = Entry::make() @@ -113,6 +114,36 @@ expect($this->event->nextOccurrences()[0]->start)->toEqual(Carbon::parse('2019-11-24')->setTimeFromTimeString('11:00:00')); }); +test('can collapse a multi day event into one occurrence', function () { + $event = EventFactory::createFromEntry($this->entry, collapseMultiDays: true); + + $occurrences = $event->occurrencesBetween( + Carbon::parse('2019-11-23')->startOfDay(), + Carbon::parse('2019-11-25')->endOfDay(), + ); + + expect($occurrences)->toHaveCount(1) + ->and($occurrences->first()->start)->toEqual(Carbon::parse('2019-11-23 19:00')->shiftTimezone('America/Vancouver')) + ->and($occurrences->first()->end)->toEqual(Carbon::parse('2019-11-25 15:00')->shiftTimezone('America/Vancouver')); +}); + +test('can collapse upcoming multi day occurrences', function () { + Carbon::setTestNowAndTimezone('2019-11-22', 'America/Vancouver'); + + $event = EventFactory::createFromEntry($this->entry, collapseMultiDays: true); + + expect($event->nextOccurrences(3))->toHaveCount(1); +}); + +test('does not collapse a multi day event by default', function () { + $occurrences = $this->event->occurrencesBetween( + Carbon::parse('2019-11-23')->startOfDay(), + Carbon::parse('2019-11-25')->endOfDay(), + ); + + expect($occurrences)->toHaveCount(3); +}); + test('day is all day when no start and end time', function () { $days = $this->allDayEvent->days(); From 8274d54c2022b38c3f31dc30d5878dc8293c2b47 Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Tue, 8 Sep 2026 15:44:43 -0700 Subject: [PATCH 2/3] Reconcile the contradicting ICS field docs (#205) --- DOCUMENTATION.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b1f2579..ec08324 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -40,11 +40,16 @@ If you are using a different Statamic collection, update it in the addon setting ### ICS Downloads -ICS downloads use the following fields if they exist: +ICS downloads read the following entry fields when present: -- `address` -- `coordinates` -- `description` +| ICS property | Source | +|---|---| +| `LOCATION` | `address`, falling back to `location` — only when the value is a string | +| `URL` | `link`, falling back to `location` when that string is a URL | +| `GEO` | `coordinates` (`latitude` / `longitude`) | +| `DESCRIPTION` | `description` | + +A URL-valued `location` (with no separate `address` / `link`) currently emits **both** `LOCATION:` and `URL:`. The `coordinates` field must be a keyed array: @@ -262,7 +267,4 @@ Generates an ICS download link. **Parameters:** - `date` date get occurrences to download -Includes: -- `location` -- `description` -- `link` +Includes the fields documented under [ICS Downloads](#ics-downloads). From 961820d916757d92bb2cff672beeade0518c6b1a Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Tue, 8 Sep 2026 16:06:57 -0700 Subject: [PATCH 3/3] Fix multi-day ICS downloads dropping location fields (#206) --- src/Types/Event.php | 42 ++++++--- src/Types/MultiDayEvent.php | 51 ++++------- src/Types/RecurringEvent.php | 25 +----- tests/Http/Contollers/IcsControllerTest.php | 96 +++++++++++++++++++++ 4 files changed, 148 insertions(+), 66 deletions(-) diff --git a/src/Types/Event.php b/src/Types/Event.php index 6ca2c9c..5653473 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -105,20 +105,40 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent return null; } + return $this->decorate($this->buildICalendarEvent($date)); + } + + /** + * @return ICalendarEvent[] + */ + public function toICalendarEvents(): array + { + if (! $event = $this->toICalendarEvent($this->start())) { + return []; + } + + return [$event]; + } + + protected function buildICalendarEvent(string|CarbonInterface $date): ICalendarEvent + { $immutableDate = $this->toCarbonImmutable($date); - $iCalEvent = ICalendarEvent::create($this->event->title) + return ICalendarEvent::create($this->event->title) ->withoutTimezone() ->uniqueIdentifier($this->event->id()) ->startsAt($immutableDate->setTimeFromTimeString($this->startTime())) ->endsAt($immutableDate->setTimeFromTimeString($this->endTime())); + } + protected function decorate(ICalendarEvent $iCalEvent): ICalendarEvent + { if ($address = $this->icsAddress()) { $iCalEvent->address($address); } - if (! is_null($coords = $this->event->coordinates)) { - $iCalEvent->coordinates($coords['latitude'], $coords['longitude']); + if ($this->hasValidCoordinates($coords = $this->event->get('coordinates'))) { + $iCalEvent->coordinates((float) $coords['latitude'], (float) $coords['longitude']); } if (! is_null($description = $this->event->description)) { @@ -132,14 +152,6 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent return $iCalEvent; } - /** - * @return ICalendarEvent[] - */ - public function toICalendarEvents(): array - { - return Arr::wrap($this->toICalendarEvent($this->start())); - } - protected function eventUrl(): ?string { if (! is_null($link = $this->event->link)) { @@ -162,6 +174,14 @@ protected function icsAddress(): ?string return is_string($address) && $address !== '' ? $address : null; } + // Entry::get() is untyped; keep mixed so a bad value can't TypeError the public ICS route. + protected function hasValidCoordinates(mixed $coords): bool + { + return is_array($coords) + && is_numeric($coords['latitude'] ?? null) + && is_numeric($coords['longitude'] ?? null); + } + protected function supplement(CarbonInterface $date): ?Entry { return unserialize(serialize($this->event)) diff --git a/src/Types/MultiDayEvent.php b/src/Types/MultiDayEvent.php index 7ed1941..bc3eeca 100644 --- a/src/Types/MultiDayEvent.php +++ b/src/Types/MultiDayEvent.php @@ -11,6 +11,7 @@ use Spatie\IcalendarGenerator\Components\Event as ICalendarEvent; use Statamic\Entries\Entry; use Statamic\Fields\Values; +use Statamic\Support\Str; use TransformStudios\Events\Day; class MultiDayEvent extends Event @@ -60,47 +61,31 @@ public function start(): CarbonImmutable return $this->days->first()->start(); } - public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent + /** + * @return ICalendarEvent[] + */ + public function toICalendarEvents(): array { - if (! $this->occursOnDate($date)) { - return null; - } + return $this->days + ->values() + ->map(function (Day $day, int $index) { + $event = $this->toICalendarEvent($day->start()); + + return $event?->uniqueIdentifier(Str::slug($this->event->title).'-'.$index); + }) + ->filter() + ->all(); + } + protected function buildICalendarEvent(string|CarbonInterface $date): ICalendarEvent + { $immutableDate = $this->toCarbonImmutable($date); $day = $this->getDayFromDate($immutableDate); - $iCalEvent = ICalendarEvent::create($this->event->title) + return ICalendarEvent::create($this->event->title) ->uniqueIdentifier($this->event->id()) ->startsAt($immutableDate->setTimeFromTimeString($day->start())) ->endsAt($immutableDate->setTimeFromTimeString($day->end())); - - if ($address = $this->icsAddress()) { - $iCalEvent->address($address); - } - - if (! is_null($coords = $this->event->coordinates)) { - $iCalEvent->coordinates($coords['latitude'], $coords['longitude']); - } - - if (! is_null($description = $this->event->description)) { - $iCalEvent->description($description); - } - - if (! is_null($link = $this->eventUrl())) { - $iCalEvent->url($link); - } - - return $iCalEvent; - } - - /** - * @return ICalendarEvent[] - */ - public function toICalendarEvents(): array - { - return collect($this->days) - ->map(fn (Day $day, int $index) => $day->toICalendarEvent($this->event->title, $index)) - ->all(); } protected function rule(bool $useEnd = false): RRuleInterface diff --git a/src/Types/RecurringEvent.php b/src/Types/RecurringEvent.php index 69c8120..fb204b7 100644 --- a/src/Types/RecurringEvent.php +++ b/src/Types/RecurringEvent.php @@ -6,7 +6,6 @@ use Illuminate\Support\Arr; use RRule\RRule; use RRule\RRuleInterface; -use Spatie\IcalendarGenerator\Components\Event as ICalendarEvent; use Spatie\IcalendarGenerator\Enums\RecurrenceFrequency; use Spatie\IcalendarGenerator\ValueObjects\RRule as ICalendarRule; @@ -27,29 +26,11 @@ public function interval(): int */ public function toICalendarEvents(): array { - $iCalEvent = ICalendarEvent::create($this->event->title) - ->uniqueIdentifier($this->event->id()) - ->startsAt($this->start()) - ->endsAt($this->end()) - ->rrule($this->spatieRule()); - - if ($address = $this->icsAddress()) { - $iCalEvent->address($address); + if (! $event = $this->toICalendarEvent($this->start())) { + return []; } - if (! is_null($coords = $this->event->coordinates)) { - $iCalEvent->coordinates($coords['latitude'], $coords['longitude']); - } - - if (! is_null($description = $this->event->description)) { - $iCalEvent->description($description); - } - - if (! is_null($link = $this->eventUrl())) { - $iCalEvent->url($link); - } - - return [$iCalEvent]; + return [$event->rrule($this->spatieRule())]; } protected function rule(bool $useEnd = false): RRuleInterface diff --git a/tests/Http/Contollers/IcsControllerTest.php b/tests/Http/Contollers/IcsControllerTest.php index 1f66697..181f4d2 100755 --- a/tests/Http/Contollers/IcsControllerTest.php +++ b/tests/Http/Contollers/IcsControllerTest.php @@ -310,3 +310,99 @@ $this->assertStringNotContainsString('LOCATION:', $response->streamedContent()); }); + +test('multi-day whole-event download includes location url description and geo on every day', function () { + Carbon::setTestNow(now()); + + Entry::make() + ->slug('multi-day-whole-event') + ->collection('events') + ->id('the-multi-day-whole-event') + ->data([ + 'title' => 'Multi-day Whole Event', + 'multi_day' => true, + 'address' => '123 Main St', + 'link' => 'https://example.com/join', + 'coordinates' => [ + 'latitude' => 40, + 'longitude' => 50, + ], + 'description' => 'The description', + 'days' => [ + [ + 'date' => now()->toDateString(), + 'start_time' => '19:00', + 'end_time' => '21:00', + ], + [ + 'date' => now()->addDay()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '15:00', + ], + ], + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'event' => 'the-multi-day-whole-event', + ]))->assertDownload('multi-day-whole-event.ics')->streamedContent(); + + expect(substr_count($content, 'LOCATION:123 Main St'))->toBe(2) + ->and(substr_count($content, 'URL:https://example.com/join'))->toBe(2) + ->and(substr_count($content, 'DESCRIPTION:The description'))->toBe(2) + ->and(substr_count($content, 'GEO:40;50'))->toBe(2); +}); + +test('partial coordinates do not fatal and omit geo', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('partial-coords-event') + ->id('partial-coords-id') + ->data([ + 'title' => 'Partial Coords Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'address' => '123 Main St', + 'coordinates' => [ + 'latitude' => 40, + ], + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'partial-coords-id', + ]))->assertDownload('partial-coords-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:123 Main St', $content); + $this->assertStringNotContainsString('GEO:', $content); +}); + +test('non-numeric coordinates do not fatal and omit geo', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('bad-coords-event') + ->id('bad-coords-id') + ->data([ + 'title' => 'Bad Coords Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'address' => '123 Main St', + 'coordinates' => [ + 'latitude' => 'north', + 'longitude' => 'west', + ], + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'bad-coords-id', + ]))->assertDownload('bad-coords-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:123 Main St', $content); + $this->assertStringNotContainsString('GEO:', $content); +});