diff --git a/README.md b/README.md index b729708..a490e0d 100644 --- a/README.md +++ b/README.md @@ -421,6 +421,31 @@ Checks if the business is closed right now. $openingHours->isClosed(); ``` +#### `OpeningHours::isAlwaysOpen(): bool` + +Checks if the business is open 24/7, has no exceptions and no filters. + +```php +if ($openingHours->isAlwaysOpen()) { + echo 'This business is open all day long every day.'; +} +``` + +#### `OpeningHours::isAlwaysClosed(): bool` + +Checks if the business is never open, has no exceptions and no filters. + +`OpeningHours` accept empty array or list with every week day empty with no prejudices. + +If it's not a valid state in your domain, you should use this method to throw an exception +or show an error. + +```php +if ($openingHours->isAlwaysClosed()) { + throw new RuntimeException('Opening hours missing'); +} +``` + #### `OpeningHours::nextOpen` ```php diff --git a/src/OpeningHours.php b/src/OpeningHours.php index 97c4699..6525f5b 100644 --- a/src/OpeningHours.php +++ b/src/OpeningHours.php @@ -20,7 +20,6 @@ use Spatie\OpeningHours\Helpers\DataTrait; use Spatie\OpeningHours\Helpers\DateTimeCopier; use Spatie\OpeningHours\Helpers\DiffTrait; -use ValueError; class OpeningHours { @@ -128,9 +127,15 @@ public static function createFromStructuredData( string|DateTimeZone|null $timezone = null, string|DateTimeZone|null $outputTimezone = null, ): self { - $parser = new OpeningHoursSpecificationParser($structuredData); - - return new static($parser->getOpeningHours(), $timezone, $outputTimezone); + return new static( + array_merge( + // https://schema.org/OpeningHoursSpecification allows overflow by default + ['overflow' => true], + OpeningHoursSpecificationParser::create($structuredData)->getOpeningHours(), + ), + $timezone, + $outputTimezone, + ); } /** @@ -213,7 +218,7 @@ public static function isValid(array $data): bool static::create($data); return true; - } catch (Exception|ValueError) { + } catch (Exception) { return false; } } @@ -891,6 +896,11 @@ protected function getDateWithTimezone(DateTimeInterface $date, ?DateTimeZone $t return $date; } + /** + * Returns opening hours for the days that match a given condition as an array. + * + * @return OpeningHoursForDay[] + */ public function filter(callable $callback): array { return Arr::filter($this->openingHours, $callback); @@ -906,6 +916,11 @@ public function flatMap(callable $callback): array return Arr::flatMap($this->openingHours, $callback); } + /** + * Returns opening hours for the exceptions that match a given condition as an array. + * + * @return OpeningHoursForDay[] + */ public function filterExceptions(callable $callback): array { return Arr::filter($this->exceptions, $callback); @@ -921,6 +936,14 @@ public function flatMapExceptions(callable $callback): array return Arr::flatMap($this->exceptions, $callback); } + /** Checks that opening hours for every day of the week matches a given condition */ + public function every(callable $callback): bool + { + return $this->filter( + static fn (OpeningHoursForDay $day) => ! $callback($day), + ) === []; + } + public function asStructuredData( string $format = TimeDataContainer::TIME_FORMAT, DateTimeZone|string|null $timezone = null, @@ -965,6 +988,20 @@ static function (OpeningHoursForDay $openingHoursForDay, string $date) use ($for return array_merge($regularHours, $exceptions); } + public function isAlwaysClosed(): bool + { + return $this->exceptions === [] && $this->filters === [] && $this->every( + static fn (OpeningHoursForDay $day) => $day->isEmpty(), + ); + } + + public function isAlwaysOpen(): bool + { + return $this->exceptions === [] && $this->filters === [] && $this->every( + static fn (OpeningHoursForDay $day) => ((string) $day) === '00:00-24:00', + ); + } + private static function filterHours(array $data, array $excludedKeys): Generator { foreach ($data as $key => $value) { diff --git a/src/OpeningHoursSpecificationParser.php b/src/OpeningHoursSpecificationParser.php index 5e6fc78..fabd81c 100644 --- a/src/OpeningHoursSpecificationParser.php +++ b/src/OpeningHoursSpecificationParser.php @@ -11,76 +11,48 @@ final class OpeningHoursSpecificationParser { private array $openingHours = []; - public function __construct(array|string|null $openingHoursSpecification) + private function __construct(array $openingHoursSpecification) { - if (is_string($openingHoursSpecification)) { + foreach ($openingHoursSpecification as $index => $openingHoursSpecificationItem) { try { - $openingHoursSpecification = json_decode( - $openingHoursSpecification, - true, - flags: JSON_THROW_ON_ERROR, - ); - } catch (JsonException $e) { + $this->parseOpeningHoursSpecificationItem($openingHoursSpecificationItem); + } catch (InvalidOpeningHoursSpecification $exception) { + $message = $exception->getMessage(); + throw new InvalidOpeningHoursSpecification( - 'Invalid https://schema.org/OpeningHoursSpecification JSON', - previous: $e, + "Invalid openingHoursSpecification item at index $index: $message", + previous: $exception, ); } } + } - if (! is_array($openingHoursSpecification) || $openingHoursSpecification === []) { + public static function createFromArray(array $openingHoursSpecification): self + { + return new self($openingHoursSpecification); + } + + public static function createFromString(string $openingHoursSpecification): self + { + try { + return self::createFromArray(json_decode( + $openingHoursSpecification, + true, + flags: JSON_THROW_ON_ERROR, + )); + } catch (JsonException $e) { throw new InvalidOpeningHoursSpecification( - 'Invalid https://schema.org/OpeningHoursSpecification structured data', + 'Invalid https://schema.org/OpeningHoursSpecification JSON', + previous: $e, ); } + } - foreach ($openingHoursSpecification as $openingHoursSpecificationItem) { - if (isset($openingHoursSpecificationItem['dayOfWeek'])) { - /* - * Regular opening hours - */ - $dayOfWeek = $openingHoursSpecificationItem['dayOfWeek']; - if (is_array($dayOfWeek)) { - // Multiple days of week for same specification - foreach ($dayOfWeek as $dayOfWeekItem) { - $this->addDayOfWeekHours( - $dayOfWeekItem, - $openingHoursSpecificationItem['opens'] ?? null, - $openingHoursSpecificationItem['closes'] ?? null, - ); - } - } elseif (is_string($dayOfWeek)) { - $this->addDayOfWeekHours( - $dayOfWeek, - $openingHoursSpecificationItem['opens'] ?? null, - $openingHoursSpecificationItem['closes'] ?? null, - ); - } else { - throw new InvalidOpeningHoursSpecification( - 'Invalid https://schema.org/OpeningHoursSpecification structured data', - ); - } - } elseif ( - isset($openingHoursSpecificationItem['validFrom']) && - isset($openingHoursSpecificationItem['validThrough']) - ) { - /* - * Exception opening hours - */ - $validFrom = $openingHoursSpecificationItem['validFrom']; - $validThrough = $openingHoursSpecificationItem['validThrough']; - $this->addExceptionsHours( - $validFrom, - $validThrough, - $openingHoursSpecificationItem['opens'] ?? null, - $openingHoursSpecificationItem['closes'] ?? null, - ); - } else { - throw new InvalidOpeningHoursSpecification( - 'Invalid https://schema.org/OpeningHoursSpecification structured data', - ); - } - } + public static function create(array|string $openingHoursSpecification): self + { + return is_string($openingHoursSpecification) + ? self::createFromString($openingHoursSpecification) + : self::createFromArray($openingHoursSpecification); } public function getOpeningHours(): array @@ -88,6 +60,26 @@ public function getOpeningHours(): array return $this->openingHours; } + /** + * Regular opening hours. + */ + private function addDaysOfWeek( + array $dayOfWeek, + mixed $opens, + mixed $closes, + ): void { + // Multiple days of week for same specification + foreach ($dayOfWeek as $dayOfWeekItem) { + if (! is_string($dayOfWeekItem)) { + throw new InvalidOpeningHoursSpecification( + 'Invalid https://schema.org/OpeningHoursSpecification dayOfWeek', + ); + } + + $this->addDayOfWeekHours($dayOfWeekItem, $opens, $closes); + } + } + private function schemaOrgDayToString(string $schemaOrgDaySpec): string { // Support official and Google-flavored Day specifications @@ -99,14 +91,19 @@ private function schemaOrgDayToString(string $schemaOrgDaySpec): string 'Friday', 'https://schema.org/Friday' => 'friday', 'Saturday', 'https://schema.org/Saturday' => 'saturday', 'Sunday', 'https://schema.org/Sunday' => 'sunday', - default => throw new InvalidOpeningHoursSpecification('Invalid https://schema.org Day specification'), + 'PublicHolidays', 'https://schema.org/PublicHolidays' => throw new InvalidOpeningHoursSpecification( + 'PublicHolidays not supported', + ), + default => throw new InvalidOpeningHoursSpecification( + 'Invalid https://schema.org Day specification', + ), }; } private function addDayOfWeekHours( string $dayOfWeek, - ?string $opens, - ?string $closes, + mixed $opens, + mixed $closes, ): void { $dayOfWeek = self::schemaOrgDayToString($dayOfWeek); @@ -120,20 +117,17 @@ private function addDayOfWeekHours( } private function addExceptionsHours( - ?string $validFrom, - ?string $validThrough, - ?string $opens, - ?string $closes + string $validFrom, + string $validThrough, + mixed $opens, + mixed $closes, ): void { - if (! is_string($validFrom) || ! is_string($validThrough)) { - throw new InvalidOpeningHoursSpecification('Missing validFrom and validThrough dates'); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $validFrom)) { + throw new InvalidOpeningHoursSpecification('Invalid validFrom date'); } - if ( - ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $validFrom) || - ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $validThrough) - ) { - throw new InvalidOpeningHoursSpecification('Invalid validFrom and validThrough dates'); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $validThrough)) { + throw new InvalidOpeningHoursSpecification('Invalid validThrough date'); } $exceptionKey = $validFrom === $validThrough ? $validFrom : $validFrom.' to '.$validThrough; @@ -151,19 +145,24 @@ private function addExceptionsHours( $this->openingHours['exceptions'][$exceptionKey][] = $hours; } - private function formatHours( - ?string $opens, - ?string $closes, - ): ?string { - if (! is_string($opens) || ! is_string($closes)) { - throw new InvalidOpeningHoursSpecification('Missing opens and closes hours'); + private function formatHours(mixed $opens, mixed $closes): ?string + { + if ($opens === null) { + if ($closes !== null) { + throw new InvalidOpeningHoursSpecification( + 'Property opens and closes must be both null or both string', + ); + } + + return null; + } + + if (! is_string($opens) || ! preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $opens)) { + throw new InvalidOpeningHoursSpecification('Invalid opens hour'); } - if ( - ! preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $opens) || - ! preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $closes) - ) { - throw new InvalidOpeningHoursSpecification('Invalid opens and closes hours'); + if (! is_string($closes) || ! preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $closes)) { + throw new InvalidOpeningHoursSpecification('Invalid closes hours'); } // strip seconds part if present @@ -175,6 +174,52 @@ private function formatHours( return null; } - return $opens.'-'.$closes; + return $opens.'-'.($closes === '23:59' ? '24:00' : $closes); + } + + private function parseOpeningHoursSpecificationItem(mixed $openingHoursSpecificationItem): void + { + // extract $openingHoursSpecificationItem keys into variables + [ + 'dayOfWeek' => $dayOfWeek, + 'validFrom' => $validFrom, + 'validThrough' => $validThrough, + 'opens' => $opens, + 'closes' => $closes, + ] = array_merge([ + // Default values: + 'dayOfWeek' => null, + 'validFrom' => null, + 'validThrough' => null, + 'opens' => null, + 'closes' => null, + ], $openingHoursSpecificationItem); + + if ($dayOfWeek !== null) { + if (is_string($dayOfWeek)) { + $dayOfWeek = [$dayOfWeek]; + } + + if (! is_array($dayOfWeek)) { + throw new InvalidOpeningHoursSpecification( + 'Property dayOfWeek must be a string or an array of strings', + ); + } + + $this->addDaysOfWeek($dayOfWeek, $opens, $closes); + + return; + } + + if (! is_string($validFrom) || ! is_string($validThrough)) { + throw new InvalidOpeningHoursSpecification( + 'Contains neither dayOfWeek nor validFrom and validThrough dates', + ); + } + + /* + * Exception opening hours + */ + $this->addExceptionsHours($validFrom, $validThrough, $opens, $closes); } } diff --git a/tests/OpeningHoursFillTest.php b/tests/OpeningHoursFillTest.php index e89a4b8..99c8ffc 100644 --- a/tests/OpeningHoursFillTest.php +++ b/tests/OpeningHoursFillTest.php @@ -11,7 +11,6 @@ use Spatie\OpeningHours\OpeningHours; use Spatie\OpeningHours\OpeningHoursForDay; use Spatie\OpeningHours\TimeRange; -use ValueError; class OpeningHoursFillTest extends TestCase { diff --git a/tests/OpeningHoursSpecificationParserTest.php b/tests/OpeningHoursSpecificationParserTest.php index 10fb471..703a806 100644 --- a/tests/OpeningHoursSpecificationParserTest.php +++ b/tests/OpeningHoursSpecificationParserTest.php @@ -4,7 +4,10 @@ namespace Spatie\OpeningHours\Test; +use DateTimeImmutable; use PHPUnit\Framework\TestCase; +use Spatie\OpeningHours\Day; +use Spatie\OpeningHours\Exceptions\InvalidOpeningHoursSpecification; use Spatie\OpeningHours\OpeningHours; class OpeningHoursSpecificationParserTest extends TestCase @@ -68,7 +71,7 @@ public function testCreateFromStructuredData(): void ] JSON; - $openingHours = OpeningHours::createFromStructuredData(json_decode($openingHoursSpecs, true)); + $openingHours = OpeningHours::createFromStructuredData($openingHoursSpecs); $this->assertInstanceOf(OpeningHours::class, $openingHours); $this->assertCount(2, $openingHours->forDay('monday')); @@ -100,4 +103,225 @@ public function testCreateFromStructuredData(): void 'Opened on 2023 Sunday before Christmas day', ); } + + public function testEmptySpecs(): void + { + $openingHours = OpeningHours::createFromStructuredData([]); + + $this->assertTrue($openingHours->isAlwaysClosed()); + } + + public function testRangeOverNight(): void + { + $openingHours = OpeningHours::createFromStructuredData([ + [ + 'dayOfWeek' => 'Monday', + 'opens' => '18:00', + 'closes' => '02:00', + ], + ]); + + $this->assertTrue($openingHours->isClosedAt(new DateTimeImmutable('2023-11-27 17:50'))); + $this->assertTrue($openingHours->isOpenAt(new DateTimeImmutable('2023-11-27 23:55'))); + $this->assertTrue($openingHours->isOpenAt(new DateTimeImmutable('2023-11-27 23:59:59.99'))); + $this->assertTrue($openingHours->isOpenAt(new DateTimeImmutable('2023-11-28 01:50'))); + $this->assertTrue($openingHours->isClosedAt(new DateTimeImmutable('2023-11-28 19:00'))); + } + + public function testH24Specs(): void + { + $openingHours = OpeningHours::createFromStructuredData([ + [ + 'opens' => '00:00', + 'closes' => '23:59', + 'dayOfWeek' => [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + ], + ], + ]); + + $this->assertTrue( + $openingHours->isOpenAt(new DateTimeImmutable('2023-11-27 23:59:34')), + 'As per specs, 23:59 is assumed to mean until end of day', + ); + $this->assertFalse( + $openingHours->isOpenAt(new DateTimeImmutable('2023-11-25 23:59:34')), + 'Saturday and Sunday not specified means they are closed', + ); + $this->assertFalse( + $openingHours->isAlwaysOpen(), + 'Saturday and Sunday not specified means they are closed', + ); + + $openingHours = OpeningHours::createFromStructuredData([ + [ + 'opens' => '00:00', + 'closes' => '23:59', + 'dayOfWeek' => [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ], + ], + ]); + + $this->assertTrue( + $openingHours->isAlwaysOpen(), + 'As per specs, 23:59 is assumed to mean until end of day', + ); + } + + public function testClosedDay(): void + { + $openingHours = OpeningHours::createFromStructuredData([ + ['dayOfWeek' => 'Monday'], + ]); + + $this->assertSame('', (string) $openingHours->forDay(Day::MONDAY)); + } + + public function testInvalidJson(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid https://schema.org/OpeningHoursSpecification JSON', + )); + + OpeningHours::createFromStructuredData('{'); + } + + public function testInvalidDayOfWeek(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid openingHoursSpecification item at index 1: Property dayOfWeek must be a string or an array of strings', + )); + + OpeningHours::createFromStructuredData([ + ['dayOfWeek' => []], + ['dayOfWeek' => true], + ]); + } + + public function testInvalidDayType(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid openingHoursSpecification item at index 0: Invalid https://schema.org/OpeningHoursSpecification dayOfWeek', + )); + + OpeningHours::createFromStructuredData([ + ['dayOfWeek' => [true]], + ]); + } + + public function testInvalidDayName(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid openingHoursSpecification item at index 0: Invalid https://schema.org Day specification', + )); + + OpeningHours::createFromStructuredData([ + ['dayOfWeek' => ['Wedmonday']], + ]); + } + + public function testUnsupportedPublicHolidays(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid openingHoursSpecification item at index 0: PublicHolidays not supported', + )); + + OpeningHours::createFromStructuredData([ + ['dayOfWeek' => 'PublicHolidays'], + ]); + } + + public function testInvalidValidPair(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid openingHoursSpecification item at index 0: Contains neither dayOfWeek nor validFrom and validThrough dates', + )); + + OpeningHours::createFromStructuredData([ + ['validFrom' => '2023-11-25'], + ]); + } + + public function testInvalidOpens(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid openingHoursSpecification item at index 0: Invalid opens hour', + )); + + OpeningHours::createFromStructuredData([ + [ + 'dayOfWeek' => 'Monday', + 'opens' => 'noon', + 'closes' => '14:00', + ], + ]); + } + + public function testInvalidCloses(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid openingHoursSpecification item at index 0: Invalid closes hour', + )); + + OpeningHours::createFromStructuredData([ + [ + 'dayOfWeek' => 'Monday', + 'opens' => '10:00', + 'closes' => 'noon', + ], + ]); + } + + public function testClosesOnly(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid openingHoursSpecification item at index 0: Property opens and closes must be both null or both string', + )); + + OpeningHours::createFromStructuredData([ + [ + 'dayOfWeek' => 'Monday', + 'closes' => '10:00', + ], + ]); + } + + public function testInvalidValidFrom(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid openingHoursSpecification item at index 0: Invalid validFrom date', + )); + + OpeningHours::createFromStructuredData([ + [ + 'validFrom' => '11/11/2023', + 'validThrough' => '2023-11-25', + ], + ]); + } + + public function testInvalidValidThrough(): void + { + self::expectExceptionObject(new InvalidOpeningHoursSpecification( + 'Invalid openingHoursSpecification item at index 0: Invalid validThrough date', + )); + + OpeningHours::createFromStructuredData([ + [ + 'validFrom' => '2023-11-11', + 'validThrough' => '25/11/20235', + ], + ]); + } } diff --git a/tests/OpeningHoursTest.php b/tests/OpeningHoursTest.php index 87dbcb1..71a9588 100644 --- a/tests/OpeningHoursTest.php +++ b/tests/OpeningHoursTest.php @@ -957,7 +957,8 @@ public function it_can_determine_that_its_open_now() /** @test */ public function it_can_use_day_enum() { - $openingHours = new class () extends OpeningHours { + $openingHours = new class extends OpeningHours + { public readonly array $days; public function __construct()