From b7b303f442e00fa15c7aaa4ab0b10ee4ee86c0aa Mon Sep 17 00:00:00 2001 From: KyleKatarn Date: Sun, 24 Jul 2022 16:55:01 +0200 Subject: [PATCH 1/3] Apply timezone for all methods and both input/output --- src/OpeningHours.php | 190 ++++++++++++++++++++------ src/OpeningHoursForDay.php | 4 +- tests/OpeningHoursCustomClassTest.php | 79 +++++++++++ 3 files changed, 232 insertions(+), 41 deletions(-) diff --git a/src/OpeningHours.php b/src/OpeningHours.php index f929a8c..feb8308 100644 --- a/src/OpeningHours.php +++ b/src/OpeningHours.php @@ -36,6 +36,9 @@ class OpeningHours /** @var DateTimeZone|null */ protected $timezone = null; + /** @var DateTimeZone|null */ + protected $outputTimezone = null; + /** @var bool Allow for overflowing time ranges which overflow into the next day */ protected $overflow; @@ -45,17 +48,16 @@ class OpeningHours /** @var string */ protected $dateTimeClass = DateTime::class; - public function __construct($timezone = null) + /** + * @param string|DateTimeZone|null $timezone + * @param string|DateTimeZone|null $outputTimezone + */ + public function __construct($timezone = null, $outputTimezone = null) { - if ($timezone instanceof DateTimeZone) { - $this->timezone = $timezone; - } elseif (is_string($timezone)) { - $this->timezone = new DateTimeZone($timezone); - } elseif ($timezone) { - throw InvalidTimezone::create(); - } + $this->setTimezone($timezone); + $this->setOutputTimezone($outputTimezone); - $this->openingHours = Day::mapDays(function () { + $this->openingHours = Day::mapDays(static function () { return new OpeningHoursForDay(); }); } @@ -74,11 +76,12 @@ public function __construct($timezone = null) * overflow?: bool, * } $data * @param string|DateTimeZone|null $timezone + * @param string|DateTimeZone|null $outputTimezone * @return static */ - public static function create(array $data, $timezone = null): self + public static function create(array $data, $timezone = null, $outputTimezone = null): self { - return (new static($timezone))->fill($data); + return (new static($timezone, $outputTimezone))->fill($data); } /** @@ -143,11 +146,12 @@ public static function mergeOverlappingRanges(array $data, array $excludedKeys = * overflow?: bool, * } $data * @param string|DateTimeZone|null $timezone + * @param string|DateTimeZone|null $outputTimezone * @return static */ - public static function createAndMergeOverlappingRanges(array $data, $timezone = null) + public static function createAndMergeOverlappingRanges(array $data, $timezone = null, $outputTimezone = null) { - return static::create(static::mergeOverlappingRanges($data), $timezone); + return static::create(static::mergeOverlappingRanges($data), $timezone, $outputTimezone); } /** @@ -221,6 +225,21 @@ public function getFilters(): array public function fill(array $data) { + $timezones = array_key_exists('timezone', $data) ? $data['timezone'] : []; + unset($data['timezone']); + + if (!is_array($timezones)) { + $timezones = ['input' => $timezones]; + } + + if (array_key_exists('input', $timezones)) { + $this->timezone = $this->parseTimezone($timezones['input']); + } + + if (array_key_exists('output', $timezones)) { + $this->outputTimezone = $this->parseTimezone($timezones['output']); + } + list($openingHours, $exceptions, $metaData, $filters, $overflow, $dateTimeClass) = $this ->parseOpeningHoursAndExceptions($data); @@ -312,6 +331,8 @@ public function forDate(DateTimeInterface $date): OpeningHoursForDay */ public function forDateTime(DateTimeInterface $date): array { + $date = $this->applyTimezone($date); + return array_merge( iterator_to_array($this->forDate( $this->yesterday($date) @@ -376,6 +397,7 @@ public function isClosed(): bool public function currentOpenRange(DateTimeInterface $dateTime) { + $dateTime = $this->applyTimezone($dateTime); $list = $this->forDateTime($dateTime); return end($list) ?: false; @@ -383,6 +405,8 @@ public function currentOpenRange(DateTimeInterface $dateTime) public function currentOpenRangeStart(DateTimeInterface $dateTime) { + $outputTimezone = $this->getOutputTimezone($dateTime); + $dateTime = $this->applyTimezone($dateTime); /** @var TimeRange $range */ $range = $this->currentOpenRange($dateTime); @@ -398,11 +422,16 @@ public function currentOpenRangeStart(DateTimeInterface $dateTime) $dateTime = $dateTime->modify('-1 day'); } - return $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0); + return $this->getDateWithTimezone( + $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0), + $outputTimezone + ); } public function currentOpenRangeEnd(DateTimeInterface $dateTime) { + $outputTimezone = $this->getOutputTimezone($dateTime); + $dateTime = $this->applyTimezone($dateTime); /** @var TimeRange $range */ $range = $this->currentOpenRange($dateTime); @@ -418,12 +447,16 @@ public function currentOpenRangeEnd(DateTimeInterface $dateTime) $dateTime = $dateTime->modify('+1 day'); } - return $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0); + return $this->getDateWithTimezone( + $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0), + $outputTimezone + ); } public function nextOpen(DateTimeInterface $dateTime = null): DateTimeInterface { - $dateTime = $dateTime ?? new $this->dateTimeClass(); + $outputTimezone = $this->getOutputTimezone($dateTime); + $dateTime = $this->applyTimezone($dateTime ?? new $this->dateTimeClass()); $dateTime = $this->copyDateTime($dateTime); $openingHoursForDay = $this->forDate($dateTime); $nextOpen = $openingHoursForDay->nextOpen(Time::fromDateTime($dateTime)); @@ -442,7 +475,7 @@ public function nextOpen(DateTimeInterface $dateTime = null): DateTimeInterface ->setTime(0, 0, 0); if ($this->isOpenAt($dateTime) && ! $openingHoursForDay->isOpenAt(Time::fromString('23:59'))) { - return $dateTime; + return $this->getDateWithTimezone($dateTime, $outputTimezone); } $openingHoursForDay = $this->forDate($dateTime); @@ -451,17 +484,24 @@ public function nextOpen(DateTimeInterface $dateTime = null): DateTimeInterface } if ($dateTime->format('H:i') === '00:00' && $this->isOpenAt((clone $dateTime)->modify('-1 second'))) { - return $this->nextOpen($dateTime->modify('+1 minute')); + return $this->getDateWithTimezone( + $this->nextOpen($dateTime->modify('+1 minute')), + $outputTimezone + ); } $nextDateTime = $nextOpen->toDateTime(); - return $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0); + return $this->getDateWithTimezone( + $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0), + $outputTimezone + ); } public function nextClose(DateTimeInterface $dateTime = null): DateTimeInterface { - $dateTime = $dateTime ?? new $this->dateTimeClass(); + $outputTimezone = $this->getOutputTimezone($dateTime); + $dateTime = $this->applyTimezone($dateTime ?? new $this->dateTimeClass()); $dateTime = $this->copyDateTime($dateTime); $nextClose = null; if ($this->overflow) { @@ -496,7 +536,7 @@ public function nextClose(DateTimeInterface $dateTime = null): DateTimeInterface ->setTime(0, 0, 0); if ($this->isClosedAt($dateTime) && $openingHoursForDay->isOpenAt(Time::fromString('23:59'))) { - return $dateTime; + return $this->getDateWithTimezone($dateTime, $outputTimezone); } $openingHoursForDay = $this->forDate($dateTime); @@ -506,12 +546,16 @@ public function nextClose(DateTimeInterface $dateTime = null): DateTimeInterface $nextDateTime = $nextClose->toDateTime(); - return $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0); + return $this->getDateWithTimezone( + $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0), + $outputTimezone + ); } public function previousOpen(DateTimeInterface $dateTime): DateTimeInterface { - $dateTime = $this->copyDateTime($dateTime); + $outputTimezone = $this->getOutputTimezone($dateTime); + $dateTime = $this->copyDateTime($this->applyTimezone($dateTime)); $openingHoursForDay = $this->forDate($dateTime); $previousOpen = $openingHoursForDay->previousOpen(Time::fromDateTime($dateTime)); $tries = $this->getDayLimit(); @@ -531,7 +575,7 @@ public function previousOpen(DateTimeInterface $dateTime): DateTimeInterface $openingHoursForDay = $this->forDate($dateTime); if ($this->isOpenAt($midnight) && ! $openingHoursForDay->isOpenAt(Time::fromString('23:59'))) { - return $midnight; + return $this->getDateWithTimezone($midnight, $outputTimezone); } $previousOpen = $openingHoursForDay->previousOpen(Time::fromDateTime($dateTime)); @@ -539,12 +583,16 @@ public function previousOpen(DateTimeInterface $dateTime): DateTimeInterface $nextDateTime = $previousOpen->toDateTime(); - return $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0); + return $this->getDateWithTimezone( + $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0), + $outputTimezone + ); } public function previousClose(DateTimeInterface $dateTime): DateTimeInterface { - $dateTime = $this->copyDateTime($dateTime); + $outputTimezone = $this->getOutputTimezone($dateTime); + $dateTime = $this->copyDateTime($this->applyTimezone($dateTime)); $previousClose = null; if ($this->overflow) { $dateTimeMinus1Day = $this->copyDateTime($dateTime)->modify('-1 day'); @@ -575,7 +623,7 @@ public function previousClose(DateTimeInterface $dateTime): DateTimeInterface $openingHoursForDay = $this->forDate($dateTime); if ($this->isClosedAt($midnight) && $openingHoursForDay->isOpenAt(Time::fromString('23:59'))) { - return $midnight; + return $this->getDateWithTimezone($midnight, $outputTimezone); } $previousClose = $openingHoursForDay->previousClose(Time::fromDateTime($dateTime)); @@ -583,12 +631,15 @@ public function previousClose(DateTimeInterface $dateTime): DateTimeInterface $previousDateTime = $previousClose->toDateTime(); - return $dateTime->setTime($previousDateTime->format('G'), $previousDateTime->format('i'), 0); + return $this->getDateWithTimezone( + $dateTime->setTime($previousDateTime->format('G'), $previousDateTime->format('i'), 0), + $outputTimezone + ); } public function regularClosingDays(): array { - return array_keys($this->filter(function (OpeningHoursForDay $openingHoursForDay) { + return array_keys($this->filter(static function (OpeningHoursForDay $openingHoursForDay) { return $openingHoursForDay->isEmpty(); })); } @@ -600,18 +651,31 @@ public function regularClosingDaysISO(): array public function exceptionalClosingDates(): array { - $dates = array_keys($this->filterExceptions(function (OpeningHoursForDay $openingHoursForDay) { + $dates = array_keys($this->filterExceptions(static function (OpeningHoursForDay $openingHoursForDay) { return $openingHoursForDay->isEmpty(); })); - return Arr::map($dates, function ($date) { + return Arr::map($dates, static function ($date) { return DateTime::createFromFormat('Y-m-d', $date); }); } + /** + * @param string|DateTimeZone|null $timezone + * @return void + */ public function setTimezone($timezone) { - $this->timezone = new DateTimeZone($timezone); + $this->timezone = $this->parseTimezone($timezone); + } + + /** + * @param string|DateTimeZone|null $timezone + * @return void + */ + public function setOutputTimezone($timezone) + { + $this->outputTimezone = $this->parseTimezone($timezone); } protected function parseOpeningHoursAndExceptions(array $data): array @@ -665,7 +729,7 @@ protected function setExceptionsFromStrings(array $exceptions) $this->dayLimit = 366; } - $this->exceptions = Arr::map($exceptions, function (array $openingHours, string $date) { + $this->exceptions = Arr::map($exceptions, static function (array $openingHours, string $date) { $recurring = DateTime::createFromFormat('m-d', $date); if ($recurring === false || $recurring->format('m-d') !== $date) { @@ -693,12 +757,22 @@ protected function normalizeDayName(string $day) protected function applyTimezone(DateTimeInterface $date) { - if ($this->timezone) { + return $this->getDateWithTimezone($date, $this->timezone); + } + + /** + * @param DateTimeInterface $date + * @param DateTimeZone|null $timezone + * @return DateTimeInterface + */ + protected function getDateWithTimezone(DateTimeInterface $date, $timezone) + { + if ($timezone) { if ($date instanceof DateTime) { $date = clone $date; } - $date = $date->setTimezone($this->timezone); + $date = $date->setTimezone($timezone); } return $date; @@ -736,8 +810,8 @@ public function flatMapExceptions(callable $callback): array public function asStructuredData(string $format = 'H:i', $timezone = null): array { - $regularHours = $this->flatMap(function (OpeningHoursForDay $openingHoursForDay, string $day) use ($format, $timezone) { - return $openingHoursForDay->map(function (TimeRange $timeRange) use ($format, $timezone, $day) { + $regularHours = $this->flatMap(static function (OpeningHoursForDay $openingHoursForDay, string $day) use ($format, $timezone) { + return $openingHoursForDay->map(static function (TimeRange $timeRange) use ($format, $timezone, $day) { return [ '@type' => 'OpeningHoursSpecification', 'dayOfWeek' => ucfirst($day), @@ -747,7 +821,7 @@ public function asStructuredData(string $format = 'H:i', $timezone = null): arra }); }); - $exceptions = $this->flatMapExceptions(function (OpeningHoursForDay $openingHoursForDay, string $date) use ($format, $timezone) { + $exceptions = $this->flatMapExceptions(static function (OpeningHoursForDay $openingHoursForDay, string $date) use ($format, $timezone) { if ($openingHoursForDay->isEmpty()) { $zero = Time::fromString('00:00')->format($format, $timezone); @@ -760,7 +834,7 @@ public function asStructuredData(string $format = 'H:i', $timezone = null): arra ]]; } - return $openingHoursForDay->map(function (TimeRange $timeRange) use ($format, $date, $timezone) { + return $openingHoursForDay->map(static function (TimeRange $timeRange) use ($format, $date, $timezone) { return [ '@type' => 'OpeningHoursSpecification', 'opens' => $timeRange->start()->format($format, $timezone), @@ -792,4 +866,42 @@ private static function filterHours(array $data, array $excludedKeys): Generator yield $key => $value; } } + + /** + * @param mixed $timezone + * @return DateTimeZone|null + */ + private function parseTimezone($timezone) + { + if ($timezone instanceof DateTimeZone) { + return $timezone; + } + + if (is_string($timezone)) { + return new DateTimeZone($timezone); + } + + if ($timezone) { + throw InvalidTimezone::create(); + } + + return null; + } + + /** + * @param DateTimeInterface|null $dateTime + * @return DateTimeZone|null + */ + private function getOutputTimezone(DateTimeInterface $dateTime = null) + { + if ($this->outputTimezone !== null) { + return $this->outputTimezone; + } + + if ($this->timezone === null || $dateTime === null) { + return $this->timezone; + } + + return $dateTime->getTimezone(); + } } diff --git a/src/OpeningHoursForDay.php b/src/OpeningHoursForDay.php index e663ef6..c69f585 100644 --- a/src/OpeningHoursForDay.php +++ b/src/OpeningHoursForDay.php @@ -33,11 +33,11 @@ public static function fromStrings(array $strings) unset($strings['data']); } - uasort($strings, function ($a, $b) { + uasort($strings, static function ($a, $b) { return strcmp(static::getHoursFromRange($a), static::getHoursFromRange($b)); }); - $timeRanges = Arr::map($strings, function ($string) { + $timeRanges = Arr::map($strings, static function ($string) { return TimeRange::fromDefinition($string); }); diff --git a/tests/OpeningHoursCustomClassTest.php b/tests/OpeningHoursCustomClassTest.php index 5d28bdd..30439dd 100644 --- a/tests/OpeningHoursCustomClassTest.php +++ b/tests/OpeningHoursCustomClassTest.php @@ -24,6 +24,85 @@ public function it_can_use_immutable_date_time() $this->assertSame('2021-10-11 09:00:00', $date->format('Y-m-d H:i:s')); } + /** @test */ + public function it_can_use_timezones() + { + $openingHours = OpeningHours::create([ + 'monday' => ['09:00-18:00'], + ]); + + $date = $openingHours->nextOpen(new DateTimeImmutable('2022-07-25 07:30 UTC')); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame('2022-07-25 09:00:00 UTC', $date->format('Y-m-d H:i:s e')); + + $date = $openingHours->nextOpen(new DateTimeImmutable('2022-07-25 07:30 Europe/Oslo')); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame('2022-07-25 09:00:00 Europe/Oslo', $date->format('Y-m-d H:i:s e')); + + $openingHours = OpeningHours::create([ + 'monday' => ['09:00-18:00'], + 'timezone' => 'Europe/Oslo', + ]); + + $date = $openingHours->nextOpen(new DateTimeImmutable('2022-07-25 06:30 UTC')); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame('2022-07-25 07:00:00 UTC', $date->format('Y-m-d H:i:s e')); + + $date = $openingHours->nextOpen(new DateTimeImmutable('2022-07-25 07:30 UTC')); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame('2022-08-01 07:00:00 UTC', $date->format('Y-m-d H:i:s e')); + + $openingHours = OpeningHours::create([ + 'monday' => ['09:00-18:00'], + ], new DateTimeZone('Europe/Oslo')); + $openingHours->setOutputTimezone('Europe/Oslo'); + + $date = $openingHours->nextOpen(new DateTimeImmutable('2022-07-25 06:30 UTC')); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame('2022-07-25 09:00:00 Europe/Oslo', $date->format('Y-m-d H:i:s e')); + + $date = $openingHours->nextOpen(new DateTimeImmutable('2022-07-25 07:30 UTC')); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame('2022-08-01 09:00:00 Europe/Oslo', $date->format('Y-m-d H:i:s e')); + + $openingHours = OpeningHours::create([ + 'monday' => ['09:00-18:00'], + 'timezone' => [ + 'input' => 'Europe/Oslo', + 'output' => 'UTC', + ], + ]); + + $date = $openingHours->nextOpen(new DateTimeImmutable('2022-07-25 06:30 UTC')); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame('2022-07-25 07:00:00 UTC', $date->format('Y-m-d H:i:s e')); + + $date = $openingHours->nextOpen(new DateTimeImmutable('2022-07-25 07:30 UTC')); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame('2022-08-01 07:00:00 UTC', $date->format('Y-m-d H:i:s e')); + $openingHours = OpeningHours::create([ + 'monday' => ['09:00-18:00'], + ], 'Europe/Oslo', 'America/New_York'); + + $date = $openingHours->nextOpen(new DateTimeImmutable('2022-07-25 06:30 UTC')); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame('2022-07-25 03:00:00 America/New_York', $date->format('Y-m-d H:i:s e')); + + $date = $openingHours->nextOpen(new DateTimeImmutable('2022-07-25 07:30 UTC')); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame('2022-08-01 03:00:00 America/New_York', $date->format('Y-m-d H:i:s e')); + } + /** @test */ public function it_can_use_mocked_time() { From adf8f48e339fd4195077357917fb252bd6988e4d Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sun, 24 Jul 2022 14:55:29 +0000 Subject: [PATCH 2/3] Apply fixes from StyleCI --- src/OpeningHours.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpeningHours.php b/src/OpeningHours.php index feb8308..4e98873 100644 --- a/src/OpeningHours.php +++ b/src/OpeningHours.php @@ -228,7 +228,7 @@ public function fill(array $data) $timezones = array_key_exists('timezone', $data) ? $data['timezone'] : []; unset($data['timezone']); - if (!is_array($timezones)) { + if (! is_array($timezones)) { $timezones = ['input' => $timezones]; } @@ -868,7 +868,7 @@ private static function filterHours(array $data, array $excludedKeys): Generator } /** - * @param mixed $timezone + * @param mixed $timezone * @return DateTimeZone|null */ private function parseTimezone($timezone) From 2939f2f8f2d5f445d1495c161f2bddf4a5a3f822 Mon Sep 17 00:00:00 2001 From: KyleKatarn Date: Sun, 24 Jul 2022 17:11:00 +0200 Subject: [PATCH 3/3] Add documentation --- README.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 60fe820..883aa7d 100644 --- a/README.md +++ b/README.md @@ -231,22 +231,46 @@ The package should only be used through the `OpeningHours` class. There are also ### `Spatie\OpeningHours\OpeningHours` -#### `OpeningHours::create(array $data, $timezone = null): Spatie\OpeningHours\OpeningHours` +#### `OpeningHours::create(array $data, $timezone = null, $toutputTimezone = null): Spatie\OpeningHours\OpeningHours` Static factory method to fill the set of opening hours. -``` php +```php $openingHours = OpeningHours::create([ 'monday' => ['09:00-12:00', '13:00-18:00'], // ... ]); ``` +If no timezone is specified, `OpeningHours` will just assume you always +pass `DateTime` objects that have already the timezone matching your schedule. + +If you pass a `$timezone` as a second argument or via the array-key `'timezone'` +(it can be either a `DateTimeZone` object or a `string`), then passed dates will +be converted to this timezone at the beginning of each method, then if the method +return a date object (such as `nextOpen`, `nextClose`, `previousOpen`, +`previousClose`, `currentOpenRangeStart` or `currentOpenRangeEnd`), then it's +converted back to original timezone before output so the object can reflect +a moment in user local time while `OpeningHours` can stick in its own business +timezone. + +Alternatively you can also specify both input and output timezone (using second +and third argument) or using an array: +```php +$openingHours = OpeningHours::create([ + 'monday' => ['09:00-12:00', '13:00-18:00'], + 'timezone' => [ + 'input' => 'America/New_York', + 'output' => 'Europe/Olso', + ], +]); +``` + #### `OpeningHours::mergeOverlappingRanges(array $schedule) : array` For safety sake, creating `OpeningHours` object with overlapping ranges will throw an exception unless you pass explicitly `'overflow' => true,` in the opening hours array definition. You can also explicitly merge them. -``` php +```php $ranges = [ 'monday' => ['08:00-11:00', '10:00-12:00'], ]; @@ -263,7 +287,7 @@ Not all days are mandatory, if a day is missing, it will be set as closed. The same as `create`, but non-static. -``` php +```php $openingHours = (new OpeningHours)->fill([ 'monday' => ['09:00-12:00', '13:00-18:00'], // ...