Skip to content

Commit

Permalink
Related to #27 allow to add custom dynamic exceptions (#83)
Browse files Browse the repository at this point in the history
* Add a filter options (custom callback functions that return dynamically opening hours for a given date)
* Precise offsetSet exception message
* Add tests
  • Loading branch information
kylekatarnls authored Dec 11, 2018
1 parent c4d0971 commit f725af1
Show file tree
Hide file tree
Showing 8 changed files with 275 additions and 7 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,31 @@ $openingHours = OpeningHours::create([
]);
```

The last structure tool is the filter, it allows you to pass closures (or callable function/method reference) that take a date as parameter and returns the settings for the given date.

```php
$openingHours = OpeningHours::create([
'monday' => [
'09:00-12:00',
],
'filters' => [
function ($date) {
$year = intval($date->format('Y'));
$easterMonday = new DateTimeImmutable('2018-03-21 +'.(easter_days($year) + 1).'days');
if ($date->format('m-d') === $easterMonday->format('m-d')) {
return []; // Closed on Easter monday
// Any valid exception-array can be returned here (range of hours, with or without data)
}
// Else the filter does not apply to te given date
},
],
]);
```

If a callable is found in the `"exceptions"` property, it will be added automatically to filters so you can mix filters and exceptions both in the **exceptions** array. The first filter that returns a non-null value will have precedence over next filters and the **filters** array has precedence over the filters inside the **exceptions** array.

Warning: as we will loop on all filters for each date from which we need to retrieve opening hours and cannot neither predicate nor cache the result (can be random function) so you must be careful with filters, too many filters or long process inside filters can have a significant impact on the performance.

It can also return the next open or close `DateTime` from a given `DateTime`.

```php
Expand Down
11 changes: 11 additions & 0 deletions src/Exceptions/NonMutableOffsets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Spatie\OpeningHours\Exceptions;

class NonMutableOffsets extends Exception
{
public static function forClass(string $className): self
{
return new self("Offsets of `{$className}` objects are not mutable.");
}
}
45 changes: 39 additions & 6 deletions src/OpeningHours.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ class OpeningHours
/** @var \Spatie\OpeningHours\Day[] */
protected $openingHours = [];

/** @var array */
/** @var \Spatie\OpeningHours\OpeningHoursForDay[] */
protected $exceptions = [];

/** @var callable[] */
protected $filters = [];

/** @var DateTimeZone|null */
protected $timezone = null;

Expand Down Expand Up @@ -116,17 +119,29 @@ public static function isValid(array $data): bool
}
}

public function setFilters(array $filters)
{
$this->filters = $filters;

return $this;
}

public function getFilters(): array
{
return $this->filters;
}

public function fill(array $data)
{
list($openingHours, $exceptions, $metaData) = $this->parseOpeningHoursAndExceptions($data);
list($openingHours, $exceptions, $metaData, $filters) = $this->parseOpeningHoursAndExceptions($data);

foreach ($openingHours as $day => $openingHoursForThisDay) {
$this->setOpeningHoursFromStrings($day, $openingHoursForThisDay);
}

$this->setExceptionsFromStrings($exceptions);

return $this->setData($metaData);
return $this->setFilters($filters)->setData($metaData);
}

public function forWeek(): array
Expand Down Expand Up @@ -168,6 +183,14 @@ public function forDate(DateTimeInterface $date): OpeningHoursForDay
{
$date = $this->applyTimezone($date);

foreach ($this->filters as $filter) {
$result = $filter($date);

if (is_array($result)) {
return OpeningHoursForDay::fromStrings($result);
}
}

return $this->exceptions[$date->format('Y-m-d')] ?? ($this->exceptions[$date->format('m-d')] ?? $this->forDay(Day::onDateTime($date)));
}

Expand Down Expand Up @@ -291,14 +314,24 @@ public function setTimezone($timezone)
protected function parseOpeningHoursAndExceptions(array $data): array
{
$metaData = Arr::pull($data, 'data', null);
$exceptions = Arr::pull($data, 'exceptions', []);
$exceptions = [];
$filters = Arr::pull($data, 'filters', []);
foreach (Arr::pull($data, 'exceptions', []) as $key => $exception) {
if (is_callable($exception)) {
$filters[] = $exception;

continue;
}

$exceptions[$key] = $exception;
}
$openingHours = [];

foreach ($data as $day => $openingHoursData) {
$openingHours[$this->normalizeDayName($day)] = $openingHoursData;
}

return [$openingHours, $exceptions, $metaData];
return [$openingHours, $exceptions, $metaData, $filters];
}

protected function setOpeningHoursFromStrings(string $day, array $openingHours)
Expand Down Expand Up @@ -337,7 +370,7 @@ protected function normalizeDayName(string $day)
$day = strtolower($day);

if (! Day::isValid($day)) {
throw new InvalidDayName();
throw InvalidDayName::invalidDayName($day);
}

return $day;
Expand Down
3 changes: 2 additions & 1 deletion src/OpeningHoursForDay.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use IteratorAggregate;
use Spatie\OpeningHours\Helpers\Arr;
use Spatie\OpeningHours\Helpers\DataTrait;
use Spatie\OpeningHours\Exceptions\NonMutableOffsets;
use Spatie\OpeningHours\Exceptions\OverlappingTimeRanges;

class OpeningHoursForDay implements ArrayAccess, Countable, IteratorAggregate
Expand Down Expand Up @@ -142,7 +143,7 @@ public function offsetGet($offset)

public function offsetSet($offset, $value)
{
throw new \Exception();
throw NonMutableOffsets::forClass(static::class);
}

public function offsetUnset($offset)
Expand Down
115 changes: 115 additions & 0 deletions tests/OpeningHoursFillTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use PHPUnit\Framework\TestCase;
use Spatie\OpeningHours\TimeRange;
use Spatie\OpeningHours\OpeningHours;
use Spatie\OpeningHours\OpeningHoursForDay;
use Spatie\OpeningHours\Exceptions\InvalidDate;
use Spatie\OpeningHours\Exceptions\InvalidDayName;

Expand Down Expand Up @@ -48,6 +49,56 @@ public function it_fills_opening_hours()
$this->assertCount(0, $openingHours->forDate(new DateTimeImmutable('2016-09-26 11:00:00')));
}

/** @test */
public function it_can_map_week_with_a_callback()
{
$openingHours = OpeningHours::create([
'monday' => ['09:00-18:00'],
'tuesday' => ['10:00-18:00'],
'wednesday' => ['09:00-12:00', '14:00-18:00'],
'thursday' => [],
'friday' => ['14:00-20:00'],
'exceptions' => [
'2016-09-26' => [],
],
]);

$this->assertSame([
'monday' => 9,
'tuesday' => 10,
'wednesday' => 9,
'thursday' => null,
'friday' => 14,
'saturday' => null,
'sunday' => null,
], $openingHours->map(function (OpeningHoursForDay $ranges) {
return $ranges->isEmpty() ? null : $ranges->offsetGet(0)->start()->hours();
}));
}

/** @test */
public function it_can_map_exceptions_with_a_callback()
{
$openingHours = OpeningHours::create([
'monday' => ['09:00-18:00'],
'tuesday' => ['10:00-18:00'],
'wednesday' => ['09:00-12:00', '14:00-18:00'],
'thursday' => [],
'friday' => ['14:00-20:00'],
'exceptions' => [
'2016-09-26' => [],
'10-10' => ['14:00-20:00'],
],
]);

$this->assertSame([
'2016-09-26' => null,
'10-10' => 14,
], $openingHours->mapExceptions(function (OpeningHoursForDay $ranges) {
return $ranges->isEmpty() ? null : $ranges->offsetGet(0)->start()->hours();
}));
}

/** @test */
public function it_can_handle_empty_input()
{
Expand Down Expand Up @@ -145,13 +196,77 @@ public function it_store_meta_data()
$this->assertSame(1, $hours->forDay('wednesday')->count());
$this->assertSame(['foobar'], $hours->forDay('thursday')[0]->getData());
$this->assertNull($hours->forDay('thursday')[1]->getData());

$hours = OpeningHours::create([
'monday' => [
['09:00-12:00', 'morning'],
['13:00-18:00', 'afternoon'],
],
]);

$this->assertSame('morning', $hours->forDay('monday')[0]->getData());
$this->assertSame('afternoon', $hours->forDay('monday')[1]->getData());
}

/** @test */
public function it_handle_filters()
{
$typicalDay = [
'08:00-12:00',
'14:00-18:00',
];
$hours = OpeningHours::create([
'monday' => $typicalDay,
'tuesday' => $typicalDay,
'wednesday' => $typicalDay,
'thursday' => $typicalDay,
'friday' => $typicalDay,
'exceptions' => [
// Closure in exceptions will be handled as a filter.
function (DateTimeImmutable $date) {
if ($date->format('Y-m-d') === $date->modify('first monday of this month')->format('Y-m-d')) {
// Big lunch each first monday of the month
return [
'08:00-11:00',
'15:00-18:00',
];
}
},
],
'filters' => [
function (DateTimeImmutable $date) {
$year = intval($date->format('Y'));
$easterMonday = new DateTimeImmutable('2018-03-21 +'.(easter_days($year) + 1).'days');
if ($date->format('m-d') === $easterMonday->format('m-d')) {
return []; // Closed on Easter monday
}
},
function (DateTimeImmutable $date) use ($typicalDay) {
if ($date->format('m') === $date->format('d')) {
return [
'hours' => $typicalDay,
'data' => 'Month equals day',
];
}
},
],
]);

$this->assertCount(3, $hours->getFilters());
$this->assertSame('08:00-11:00,15:00-18:00', $hours->forDate(new DateTimeImmutable('2018-12-03'))->__toString());
$this->assertSame('08:00-12:00,14:00-18:00', $hours->forDate(new DateTimeImmutable('2018-12-10'))->__toString());
$this->assertSame('', $hours->forDate(new DateTimeImmutable('2018-04-02'))->__toString());
$this->assertSame('04-03 08:00', $hours->nextOpen(new DateTimeImmutable('2018-03-31'))->format('m-d H:i'));
$this->assertSame('12-03 11:00', $hours->nextClose(new DateTimeImmutable('2018-12-03'))->format('m-d H:i'));
$this->assertSame('Month equals day', $hours->forDate(new DateTimeImmutable('2018-12-12'))->getData());
}

/** @test */
public function it_should_merge_ranges_on_explicitly_create_from_overlapping_ranges()
{
$hours = OpeningHours::createAndMergeOverlappingRanges([
'monday' => [
'08:00-12:00',
'08:00-12:00',
'11:30-13:30',
'13:00-18:00',
Expand Down
11 changes: 11 additions & 0 deletions tests/OpeningHoursForDayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use PHPUnit\Framework\TestCase;
use Spatie\OpeningHours\TimeRange;
use Spatie\OpeningHours\OpeningHoursForDay;
use Spatie\OpeningHours\Exceptions\NonMutableOffsets;
use Spatie\OpeningHours\Exceptions\OverlappingTimeRanges;

class OpeningHoursForDayTest extends TestCase
Expand Down Expand Up @@ -77,4 +78,14 @@ public function it_can_get_iterator()

$this->assertCount(2, $openingHoursForDay->getIterator()->getArrayCopy());
}

/** @test */
public function it_cant_set_iterator_item()
{
$this->expectException(NonMutableOffsets::class);

$openingHoursForDay = OpeningHoursForDay::fromStrings(['09:00-12:00', '13:00-18:00']);

$openingHoursForDay[0] = TimeRange::fromString('07:00-11:00');
}
}
44 changes: 44 additions & 0 deletions tests/OpeningHoursTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,50 @@ public function it_can_determine_next_open_hours_from_non_working_date_time()
$this->assertEquals('2016-09-26 13:00:00', $nextTimeOpen->format('Y-m-d H:i:s'));
}

/** @test */
public function it_can_determine_next_open_hours_from_edges_time()
{
$openingHours = OpeningHours::create([
'monday' => ['09:00-11:00', '13:00-19:00'],
'tuesday' => ['09:00-11:00', '13:00-19:00'],
]);

$nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 00:00:00'));

$this->assertInstanceOf(DateTime::class, $nextTimeOpen);
$this->assertEquals('2016-09-26 09:00:00', $nextTimeOpen->format('Y-m-d H:i:s'));

$nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 09:00:00'));

$this->assertInstanceOf(DateTime::class, $nextTimeOpen);
$this->assertEquals('2016-09-26 13:00:00', $nextTimeOpen->format('Y-m-d H:i:s'));

$nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 11:00:00'));

$this->assertInstanceOf(DateTime::class, $nextTimeOpen);
$this->assertEquals('2016-09-26 13:00:00', $nextTimeOpen->format('Y-m-d H:i:s'));

$nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 12:00:00'));

$this->assertInstanceOf(DateTime::class, $nextTimeOpen);
$this->assertEquals('2016-09-26 13:00:00', $nextTimeOpen->format('Y-m-d H:i:s'));

$nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 13:00:00'));

$this->assertInstanceOf(DateTime::class, $nextTimeOpen);
$this->assertEquals('2016-09-27 09:00:00', $nextTimeOpen->format('Y-m-d H:i:s'));

$nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 19:00:00'));

$this->assertInstanceOf(DateTime::class, $nextTimeOpen);
$this->assertEquals('2016-09-27 09:00:00', $nextTimeOpen->format('Y-m-d H:i:s'));

$nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 23:00:00'));

$this->assertInstanceOf(DateTime::class, $nextTimeOpen);
$this->assertEquals('2016-09-27 09:00:00', $nextTimeOpen->format('Y-m-d H:i:s'));
}

/** @test */
public function it_can_determine_next_open_hours_from_non_working_date_time_immutable()
{
Expand Down
Loading

0 comments on commit f725af1

Please sign in to comment.