Skip to content

Commit

Permalink
- Add details in exceptions to know what is the issue precisely and w…
Browse files Browse the repository at this point in the history
…hich item is incorrect

- Accept empty array as per specs for structured-data
- Add utils to know if opening-hours are always open/closed
- Add OpeningHours::every() method
  • Loading branch information
kylekatarnls committed Nov 25, 2023
1 parent 6f0430a commit d3f3ab7
Show file tree
Hide file tree
Showing 6 changed files with 375 additions and 74 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions src/OpeningHours.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
use Spatie\OpeningHours\Helpers\DataTrait;
use Spatie\OpeningHours\Helpers\DateTimeCopier;
use Spatie\OpeningHours\Helpers\DiffTrait;
use ValueError;

class OpeningHours
{
Expand Down Expand Up @@ -215,7 +214,7 @@ public static function isValid(array $data): bool
static::create($data);

return true;
} catch (Exception|ValueError) {
} catch (Exception) {
return false;
}
}
Expand Down Expand Up @@ -893,6 +892,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);
Expand All @@ -908,6 +912,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);
Expand All @@ -923,6 +932,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,
Expand Down Expand Up @@ -967,6 +984,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) {
Expand Down
176 changes: 107 additions & 69 deletions src/OpeningHoursSpecificationParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +13,15 @@ final class OpeningHoursSpecificationParser

private function __construct(array $openingHoursSpecification)
{
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 {
foreach ($openingHoursSpecification as $index => $openingHoursSpecificationItem) {
try {
$this->parseOpeningHoursSpecificationItem($openingHoursSpecificationItem);
} catch (InvalidOpeningHoursSpecification $exception) {
$message = $exception->getMessage();

throw new InvalidOpeningHoursSpecification(
'Invalid https://schema.org/OpeningHoursSpecification structured data',
"Invalid openingHoursSpecification item at index $index: $message",
previous: $exception,
);
}
}
Expand Down Expand Up @@ -95,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
Expand All @@ -106,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);

Expand All @@ -127,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;
Expand All @@ -158,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
Expand All @@ -182,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);
}
}
1 change: 0 additions & 1 deletion tests/OpeningHoursFillTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
use Spatie\OpeningHours\OpeningHours;
use Spatie\OpeningHours\OpeningHoursForDay;
use Spatie\OpeningHours\TimeRange;
use ValueError;

class OpeningHoursFillTest extends TestCase
{
Expand Down
Loading

0 comments on commit d3f3ab7

Please sign in to comment.