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 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 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); + $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); + + return $this->daysBetween( + trim(implode('-', array_slice($dashChunks, 0, $middle))), + trim(implode('-', array_slice($dashChunks, $middle))), + ); + } + + return [$key]; + } + + /** @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'); + + $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 fc725a4..484a64c 100644 --- a/tests/OpeningHoursTest.php +++ b/tests/OpeningHoursTest.php @@ -6,7 +6,9 @@ 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; use Spatie\OpeningHours\OpeningHoursForDay; use Spatie\OpeningHours\Time; @@ -1079,7 +1081,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 +1090,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 +1099,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 +1108,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 +1125,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 +1142,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 +1162,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 +1271,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 +1280,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 +1297,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.'); @@ -1482,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'], + ], + ]); + } }