From 96128ab05c350ff60d1ceca6a61a7936f9a13baf Mon Sep 17 00:00:00 2001 From: KyleKatarn Date: Sun, 5 Nov 2023 20:02:24 +0100 Subject: [PATCH 1/5] Allow date ranges as input --- src/Exceptions/InvalidDateRange.php | 11 +++++ src/OpeningHours.php | 77 ++++++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 src/Exceptions/InvalidDateRange.php diff --git a/src/Exceptions/InvalidDateRange.php b/src/Exceptions/InvalidDateRange.php new file mode 100644 index 0000000..f4453fc --- /dev/null +++ b/src/Exceptions/InvalidDateRange.php @@ -0,0 +1,11 @@ +parseExceptions( + Arr::pull($data, 'exceptions', []), + Arr::pull($data, 'filters', []), + ); + $openingHours = $this->parseDaysOfWeeks($data); + + return [$openingHours, $exceptions, $metaData, $filters, $overflow, $dateTimeClass]; + } - foreach (Arr::pull($data, 'exceptions', []) as $key => $exception) { + protected function parseExceptions(array $data, array $filters): array + { + $exceptions = []; + + foreach ($data as $key => $exception) { if (is_callable($exception)) { $filters[] = $exception; continue; } - $exceptions[$key] = $exception; + foreach ($this->readDatesRange($key) as $date) { + if (isset($exceptions[$date])) { + throw InvalidDateRange::invalidDateRange($key, $date); + } + + $exceptions[$date] = $exception; + } } + return [$exceptions, $filters]; + } + + protected function parseDaysOfWeeks(array $data): array + { $openingHours = []; - foreach ($data as $day => $openingHoursData) { - $openingHours[$this->normalizeDayName($day)] = $openingHoursData; + foreach ($data as $dayKey => $openingHoursData) { + foreach ($this->readDatesRange($dayKey) as $rawDay) { + $day = $this->normalizeDayName($rawDay); + + if (isset($openingHours[$day])) { + throw InvalidDateRange::invalidDateRange($dayKey, $day); + } + + $openingHours[$day] = $openingHoursData; + } } - return [$openingHours, $exceptions, $metaData, $filters, $overflow, $dateTimeClass]; + return $openingHours; + } + + protected function readDatesRange(string $key): iterable + { + $toChunks = preg_split('/\sto\s/', $key, 2); + + if (count($toChunks) === 2) { + return $this->daysBetween(trim($toChunks[0]), trim($toChunks[1])); + } + + $dashChunks = explode('-', $key); + $chunksCount = count($dashChunks); + + if ($chunksCount >= 4) { + $middle = ceil($chunksCount / 2); + + return $this->daysBetween( + trim(implode('-', array_slice($dashChunks, 0, $middle))), + trim(implode('-', array_slice($dashChunks, $middle))), + ); + } + + return [$key]; + } + + protected function daysBetween(string $start, string $end): DatePeriod + { + $startDate = new DateTimeImmutable($start); + $endDate = $startDate->modify($end)->modify('+12 hours'); + + return new DatePeriod($startDate, new DateInterval('P1D'), $endDate); } protected function setOpeningHoursFromStrings(string $day, array $openingHours): void From 27b6a9b7ca0bce11bee5aef9b2c29a8ecf084fa6 Mon Sep 17 00:00:00 2001 From: kylekatarnls Date: Sat, 11 Nov 2023 20:05:40 +0100 Subject: [PATCH 2/5] Add tests for cap and limit --- tests/OpeningHoursTest.php | 121 ++++++++++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 10 deletions(-) diff --git a/tests/OpeningHoursTest.php b/tests/OpeningHoursTest.php index fc725a4..001e970 100644 --- a/tests/OpeningHoursTest.php +++ b/tests/OpeningHoursTest.php @@ -7,6 +7,7 @@ use DateTimeZone; use PHPUnit\Framework\TestCase; use Spatie\OpeningHours\Exceptions\MaximumLimitExceeded; +use Spatie\OpeningHours\Exceptions\SearchLimitReached; use Spatie\OpeningHours\OpeningHours; use Spatie\OpeningHours\OpeningHoursForDay; use Spatie\OpeningHours\Time; @@ -1079,7 +1080,7 @@ public function it_can_set_the_timezone_on_construct_with_string() } /** @test */ - public function it_throw_an_exception_on_invalid_timezone() + public function it_throws_an_exception_on_invalid_timezone() { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid Timezone'); @@ -1088,7 +1089,7 @@ public function it_throw_an_exception_on_invalid_timezone() } /** @test */ - public function it_throw_an_exception_on_limit_exceeded_void_array_next_open() + public function it_throws_an_exception_on_limit_exceeded_void_array_next_open() { $this->expectException(MaximumLimitExceeded::class); $this->expectExceptionMessage('No open date/time found in the next 8 days, use $openingHours->setDayLimit() to increase the limit.'); @@ -1097,7 +1098,7 @@ public function it_throw_an_exception_on_limit_exceeded_void_array_next_open() } /** @test */ - public function it_throw_an_exception_on_limit_exceeded_void_array_previous_open() + public function it_throws_an_exception_on_limit_exceeded_void_array_previous_open() { $this->expectException(MaximumLimitExceeded::class); $this->expectExceptionMessage('No open date/time found in the previous 8 days, use $openingHours->setDayLimit() to increase the limit.'); @@ -1106,7 +1107,7 @@ public function it_throw_an_exception_on_limit_exceeded_void_array_previous_open } /** @test */ - public function it_throw_an_exception_on_limit_exceeded_full_array_next_open() + public function it_throws_an_exception_on_limit_exceeded_full_array_next_open() { $this->expectException(MaximumLimitExceeded::class); $this->expectExceptionMessage('No open date/time found in the next 8 days, use $openingHours->setDayLimit() to increase the limit.'); @@ -1123,7 +1124,7 @@ public function it_throw_an_exception_on_limit_exceeded_full_array_next_open() } /** @test */ - public function it_throw_an_exception_on_limit_exceeded_full_array_previous_open() + public function it_throws_an_exception_on_limit_exceeded_full_array_previous_open() { $this->expectException(MaximumLimitExceeded::class); $this->expectExceptionMessage('No open date/time found in the previous 8 days, use $openingHours->setDayLimit() to increase the limit.'); @@ -1140,7 +1141,7 @@ public function it_throw_an_exception_on_limit_exceeded_full_array_previous_open } /** @test */ - public function it_throw_an_exception_on_limit_exceeded_full_array_next_open_with_exceptions() + public function it_throws_an_exception_on_limit_exceeded_full_array_next_open_with_exceptions() { $this->expectException(MaximumLimitExceeded::class); $this->expectExceptionMessage('No open date/time found in the next 366 days, use $openingHours->setDayLimit() to increase the limit.'); @@ -1160,7 +1161,107 @@ public function it_throw_an_exception_on_limit_exceeded_full_array_next_open_wit } /** @test */ - public function it_throw_an_exception_on_limit_exceeded_void_array_next_close() + public function it_throws_an_exception_on_search_limit_exceeded_with_next_open() + { + $this->expectException(SearchLimitReached::class); + $this->expectExceptionMessage('Search reached the limit: 2019-06-13 19:02:00.000000 UTC'); + + OpeningHours::create([])->nextOpen( + new DateTime('2019-06-06 19:02:00'), + new DateTime('2019-06-13 19:02:00'), + ); + } + + /** @test */ + public function it_throws_an_exception_on_search_limit_exceeded_with_next_close() + { + $this->expectException(SearchLimitReached::class); + $this->expectExceptionMessage('Search reached the limit: 2019-06-13 19:02:00.000000 UTC'); + + OpeningHours::create([])->nextClose( + new DateTime('2019-06-06 19:02:00'), + new DateTime('2019-06-13 19:02:00'), + ); + } + + /** @test */ + public function it_throws_an_exception_on_search_limit_exceeded_with_previous_open() + { + $this->expectException(SearchLimitReached::class); + $this->expectExceptionMessage('Search reached the limit: 2019-06-03 19:02:00.000000 UTC'); + + OpeningHours::create([])->previousOpen( + new DateTime('2019-06-06 19:02:00'), + new DateTime('2019-06-03 19:02:00'), + ); + } + + /** @test */ + public function it_throws_an_exception_on_search_limit_exceeded_with_previous_close() + { + $this->expectException(SearchLimitReached::class); + $this->expectExceptionMessage('Search reached the limit: 2019-06-03 19:02:00.000000 UTC'); + + OpeningHours::create([])->previousClose( + new DateTime('2019-06-06 19:02:00'), + new DateTime('2019-06-03 19:02:00'), + ); + } + + /** @test */ + public function it_stops_at_cap_limit_with_next_open() + { + $this->assertSame( + '2019-06-13 19:02:00', + OpeningHours::create([])->nextOpen( + new DateTime('2019-06-06 19:02:00'), + null, + new DateTime('2019-06-13 19:02:00'), + )->format('Y-m-d H:i:s'), + ); + } + + /** @test */ + public function it_stops_at_cap_limit_exceeded_with_next_close() + { + $this->assertSame( + '2019-06-13 19:02:00', + OpeningHours::create([])->nextClose( + new DateTime('2019-06-06 19:02:00'), + null, + new DateTime('2019-06-13 19:02:00'), + )->format('Y-m-d H:i:s'), + ); + } + + /** @test */ + public function it_stops_at_cap_limit_with_previous_open() + { + $this->assertSame( + '2019-06-03 19:02:00', + OpeningHours::create([])->previousOpen( + new DateTime('2019-06-06 19:02:00'), + null, + new DateTime('2019-06-03 19:02:00'), + )->format('Y-m-d H:i:s'), + ); + } + + /** @test */ + public function it_stops_at_cap_limit_exceeded_with_previous_close() + { + $this->assertSame( + '2019-06-03 19:02:00', + OpeningHours::create([])->previousClose( + new DateTime('2019-06-06 19:02:00'), + null, + new DateTime('2019-06-03 19:02:00'), + )->format('Y-m-d H:i:s'), + ); + } + + /** @test */ + public function it_throws_an_exception_on_limit_exceeded_void_array_next_close() { $this->expectException(MaximumLimitExceeded::class); $this->expectExceptionMessage('No close date/time found in the next 8 days, use $openingHours->setDayLimit() to increase the limit.'); @@ -1169,7 +1270,7 @@ public function it_throw_an_exception_on_limit_exceeded_void_array_next_close() } /** @test */ - public function it_throw_an_exception_on_limit_exceeded_void_array_previous_close() + public function it_throws_an_exception_on_limit_exceeded_void_array_previous_close() { $this->expectException(MaximumLimitExceeded::class); $this->expectExceptionMessage('No close date/time found in the previous 8 days, use $openingHours->setDayLimit() to increase the limit.'); @@ -1178,7 +1279,7 @@ public function it_throw_an_exception_on_limit_exceeded_void_array_previous_clos } /** @test */ - public function it_throw_an_exception_on_limit_exceeded_full_array_next_close() + public function it_throws_an_exception_on_limit_exceeded_full_array_next_close() { $this->expectException(MaximumLimitExceeded::class); $this->expectExceptionMessage('No close date/time found in the next 8 days, use $openingHours->setDayLimit() to increase the limit.'); @@ -1195,7 +1296,7 @@ public function it_throw_an_exception_on_limit_exceeded_full_array_next_close() } /** @test */ - public function it_throw_an_exception_on_limit_exceeded_full_array_previous_close() + public function it_throws_an_exception_on_limit_exceeded_full_array_previous_close() { $this->expectException(MaximumLimitExceeded::class); $this->expectExceptionMessage('No close date/time found in the previous 8 days, use $openingHours->setDayLimit() to increase the limit.'); From 1b329947ca877e405f32d448abbb2bbe395d6c65 Mon Sep 17 00:00:00 2001 From: kylekatarnls Date: Sat, 11 Nov 2023 22:25:13 +0100 Subject: [PATCH 3/5] Add tests for ranges --- src/OpeningHours.php | 25 ++++++++++++++-- tests/OpeningHoursTest.php | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/OpeningHours.php b/src/OpeningHours.php index 221f491..599daab 100644 --- a/src/OpeningHours.php +++ b/src/OpeningHours.php @@ -816,6 +816,11 @@ protected function readDatesRange(string $key): iterable $dashChunks = explode('-', $key); $chunksCount = count($dashChunks); + $firstChunk = trim($dashChunks[0]); + + if ($chunksCount === 2 && preg_match('/^[A-Za-z]+$/', $firstChunk)) { + return $this->daysBetween($firstChunk, trim($dashChunks[1])); + } if ($chunksCount >= 4) { $middle = ceil($chunksCount / 2); @@ -829,12 +834,28 @@ protected function readDatesRange(string $key): iterable return [$key]; } - protected function daysBetween(string $start, string $end): DatePeriod + /** @return Generator */ + protected function daysBetween(string $start, string $end): Generator { + $count = count(explode('-', $start)); + + if ($count === 2) { + // Use an arbitrary leap year + $start = "2024-$start"; + $end = "2024-$end"; + } + $startDate = new DateTimeImmutable($start); $endDate = $startDate->modify($end)->modify('+12 hours'); - return new DatePeriod($startDate, new DateInterval('P1D'), $endDate); + $format = [ + 2 => 'm-d', + 3 => 'Y-m-d', + ][$count] ?? 'l'; + + foreach (new DatePeriod($startDate, new DateInterval('P1D'), $endDate) as $date) { + yield $date->format($format); + } } protected function setOpeningHoursFromStrings(string $day, array $openingHours): void diff --git a/tests/OpeningHoursTest.php b/tests/OpeningHoursTest.php index 001e970..484a64c 100644 --- a/tests/OpeningHoursTest.php +++ b/tests/OpeningHoursTest.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use DateTimeZone; use PHPUnit\Framework\TestCase; +use Spatie\OpeningHours\Exceptions\InvalidDateRange; use Spatie\OpeningHours\Exceptions\MaximumLimitExceeded; use Spatie\OpeningHours\Exceptions\SearchLimitReached; use Spatie\OpeningHours\OpeningHours; @@ -1583,4 +1584,61 @@ public function testSearchWithEmptyHours() $this->assertSame(0.0, $minutes); } + + public function testRanges() + { + $openingHours = OpeningHours::create([ + 'monday - wednesday' => ['08:30-12:00', '14:30-16:00'], + 'thursday to friday' => ['14:30-18:00'], + 'saturday-sunday' => [], + 'exceptions' => [ + '2016-11-11-2016-11-14' => ['09:00-12:00'], + '11-30-12-01' => ['09:00-14:00'], + '12-24 to 12-26' => [], + '11-10 - 11-12' => ['07:00-10:00'], + ], + ]); + + $this->assertSame([ + 'monday' => '08:30-12:00,14:30-16:00', + 'tuesday' => '08:30-12:00,14:30-16:00', + 'wednesday' => '08:30-12:00,14:30-16:00', + 'thursday' => '14:30-18:00', + 'friday' => '14:30-18:00', + 'saturday' => '', + 'sunday' => '', + ], array_map( + static fn (OpeningHoursForDay $day) => (string) $day, + $openingHours->forWeek(), + )); + $this->assertSame('07:00-10:00', (string) $openingHours->forDate(new DateTimeImmutable('2016-11-10 11:00'))); + $this->assertSame('09:00-12:00', (string) $openingHours->forDate(new DateTimeImmutable('2016-11-12 11:00'))); + $this->assertSame('09:00-14:00', (string) $openingHours->forDate(new DateTimeImmutable('2023-12-01 11:00'))); + $this->assertSame('', (string) $openingHours->forDate(new DateTimeImmutable('2024-12-25 11:00'))); + } + + public function testRangesWeekOverlap() + { + $this->expectException(InvalidDateRange::class); + $this->expectExceptionMessage('Unable to record `tuesday to friday` as it would override `tuesday`.'); + + OpeningHours::create([ + 'monday - wednesday' => ['08:30-12:00', '14:30-16:00'], + 'tuesday to friday' => ['14:30-18:00'], + ]); + } + + public function testRangesExceptionOverlap() + { + $this->expectException(InvalidDateRange::class); + $this->expectExceptionMessage('Unable to record `11-10 to 11-12` as it would override `11-11`.'); + + OpeningHours::create([ + 'monday - wednesday' => ['08:30-12:00', '14:30-16:00'], + 'exceptions' => [ + '11-11-11-14' => ['09:00-12:00'], + '11-10 to 11-12' => ['07:00-10:00'], + ], + ]); + } } From 3e5643cac013f02a724fca0ae5b8133e5c9c11da Mon Sep 17 00:00:00 2001 From: kylekatarnls Date: Sun, 12 Nov 2023 09:46:46 +0100 Subject: [PATCH 4/5] Add documentation for ranges --- README.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e06537f..baa101a 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ $openingHours->forDate(new DateTime('2016-12-25')); $openingHours->exceptions(); ``` -On construction you can set a flag for overflowing times across days. For example, for a night club opens until 3am on Friday and Saturday: +On construction, you can set a flag for overflowing times across days. For example, for a nightclub opens until 3am on Friday and Saturday: ```php $openingHours = \Spatie\OpeningHours\OpeningHours::create([ @@ -102,7 +102,7 @@ $openingHours = \Spatie\OpeningHours\OpeningHours::create([ ], null); ``` -This allows the API to further at yesterdays data to check if the opening hours are open from yesterdays time range. +This allows the API to further at previous day's data to check if the opening hours are open from its time range. You can add data in definitions then retrieve them: @@ -163,6 +163,27 @@ $openingHours = OpeningHours::create([ ]); ``` +You can use the separator `to` to specify multiple days at once, for the week or for exceptions: + +```php +$openingHours = OpeningHours::create([ + 'monday to friday' => ['09:00-19:00'], + 'saturday to sunday' => [], + 'exceptions' => [ + // Every year + '12-24 to 12-26' => [ + 'hours' => [], + 'data' => 'Holidays', + ], + // Only happening in 2024 + '2024-06-25 to 2024-07-01' => [ + 'hours' => [], + 'data' => 'Closed for works', + ], + ], +]); +``` + The last structure tool is the filter, it allows you to pass closures (or callable function/method reference) that take a date as a parameter and returns the settings for the given date. ```php From 0b528927956b55ad09081a1d75f9a0ca1c31a09b Mon Sep 17 00:00:00 2001 From: kylekatarnls Date: Sun, 12 Nov 2023 09:57:22 +0100 Subject: [PATCH 5/5] Add entries in changelog --- CHANGELOG.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c44e7..ccf23ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,37 @@ All notable changes to `opening-hours` will be documented in this file -## 3.0.0 - upcoming +## 3.0.0 - 2023-11-12 - Add `Time::date()` method - Add `DateTimeRange` class +- Add ranges support via `to` or `-` separator +- Deprecate `fill()` and `setData()` +- Remove `setFilters()` + +## 2.41.0 - 2023-06-02 + +- Cap holidays check to end date when calculating diff + +## 2.13.0 - 2022-08-07 + +- Make comparison microsecond-precise + +## 2.12.0 - 2022-07-24 + +- Apply timezone for all methods and both input/output + +## 2.11.3 - 2022-07-23 + +- Copy non immutable dates to apply timezone + +## 2.11.2 - 2021-12-09 + +- Add array-shape create() PHPDoc + +## 2.11.1 - 2021-12-04 + +- Fix compatibility with PHP 8.1 ## 2.11.0 - 2021-10-16