diff --git a/src/Carbon/CarbonInterval.php b/src/Carbon/CarbonInterval.php index 7168fa4b4..2eb1a0344 100644 --- a/src/Carbon/CarbonInterval.php +++ b/src/Carbon/CarbonInterval.php @@ -25,6 +25,8 @@ use Carbon\Traits\Options; use Closure; use DateInterval; +use DateTimeInterface; +use DateTimeZone; use Exception; use ReflectionException; use ReturnTypeWillChange; @@ -253,6 +255,22 @@ class CarbonInterval extends DateInterval implements CarbonConverterInterface protected $tzName; /** + * Set the instance's timezone from a string or object. + * + * @param \DateTimeZone|string $tzName + * + * @return static + */ + public function setTimezone($tzName) + { + $this->tzName = $tzName; + + return $this; + } + + /** + * @internal + * * Set the instance's timezone from a string or object and add/subtract the offset difference. * * @param \DateTimeZone|string $tzName @@ -1763,12 +1781,20 @@ public function toDateInterval() /** * Convert the interval to a CarbonPeriod. * - * @param array ...$params Start date, [end date or recurrences] and optional settings. + * @param DateTimeInterface|string|int ...$params Start date, [end date or recurrences] and optional settings. * * @return CarbonPeriod */ public function toPeriod(...$params) { + if ($this->tzName) { + $tz = \is_string($this->tzName) ? new DateTimeZone($this->tzName) : $this->tzName; + + if ($tz instanceof DateTimeZone) { + array_unshift($params, $tz); + } + } + return CarbonPeriod::create($this, ...$params); } diff --git a/src/Carbon/CarbonPeriod.php b/src/Carbon/CarbonPeriod.php index 70e6ba49a..ae1102656 100644 --- a/src/Carbon/CarbonPeriod.php +++ b/src/Carbon/CarbonPeriod.php @@ -28,6 +28,7 @@ use DatePeriod; use DateTime; use DateTimeInterface; +use DateTimeZone; use InvalidArgumentException; use Iterator; use JsonSerializable; @@ -639,7 +640,11 @@ public function __construct(...$arguments) } foreach ($arguments as $argument) { - if ($this->dateInterval === null && + $parsedDate = null; + + if ($argument instanceof DateTimeZone) { + $this->setTimezone($argument); + } elseif ($this->dateInterval === null && ( \is_string($argument) && preg_match( '/^(-?\d(\d(?![\/-])|[^\d\/-]([\/-])?)*|P[T0-9].*|(?:\h*\d+(?:\.\d+)?\h*[a-z]+)+)$/i', @@ -648,13 +653,13 @@ public function __construct(...$arguments) $argument instanceof DateInterval || $argument instanceof Closure ) && - $parsed = @CarbonInterval::make($argument) + $parsedInterval = @CarbonInterval::make($argument) ) { - $this->setDateInterval($parsed); - } elseif ($this->startDate === null && $parsed = Carbon::make($argument)) { - $this->setStartDate($parsed); - } elseif ($this->endDate === null && $parsed = Carbon::make($argument)) { - $this->setEndDate($parsed); + $this->setDateInterval($parsedInterval); + } elseif ($this->startDate === null && $parsedDate = $this->makeDateTime($argument)) { + $this->setStartDate($parsedDate); + } elseif ($this->endDate === null && ($parsedDate = $parsedDate ?? $this->makeDateTime($argument))) { + $this->setEndDate($parsedDate); } elseif ($this->recurrences === null && $this->endDate === null && is_numeric($argument)) { $this->setRecurrences($argument); } elseif ($this->options === null && (\is_int($argument) || $argument === null)) { @@ -1689,7 +1694,30 @@ public function __call($method, $parameters) } /** - * Set the instance's timezone from a string or object and add/subtract the offset difference. + * Set the instance's timezone from a string or object and apply it to start/end. + * + * @param \DateTimeZone|string $timezone + * + * @return static + */ + public function setTimezone($timezone) + { + $this->tzName = $timezone; + $this->timezone = $timezone; + + if ($this->startDate) { + $this->setStartDate($this->startDate->setTimezone($timezone)); + } + + if ($this->endDate) { + $this->setEndDate($this->endDate->setTimezone($timezone)); + } + + return $this; + } + + /** + * Set the instance's timezone from a string or object and add/subtract the offset difference to start/end. * * @param \DateTimeZone|string $timezone * @@ -1700,6 +1728,14 @@ public function shiftTimezone($timezone) $this->tzName = $timezone; $this->timezone = $timezone; + if ($this->startDate) { + $this->setStartDate($this->startDate->shiftTimezone($timezone)); + } + + if ($this->endDate) { + $this->setEndDate($this->endDate->shiftTimezone($timezone)); + } + return $this; } @@ -2495,4 +2531,24 @@ private function orderCouple($first, $second): array { return $first > $second ? [$second, $first] : [$first, $second]; } + + private function makeDateTime($value): ?DateTimeInterface + { + if ($value instanceof DateTimeInterface) { + return $value; + } + + if (\is_string($value)) { + $value = trim($value); + + if (!preg_match('/^P[0-9T]/', $value) && + !preg_match('/^R[0-9]/', $value) && + preg_match('/[a-z0-9]/i', $value) + ) { + return Carbon::parse($value, $this->tzName); + } + } + + return null; + } } diff --git a/src/Carbon/Factory.php b/src/Carbon/Factory.php index e1d747f22..a614043f2 100644 --- a/src/Carbon/Factory.php +++ b/src/Carbon/Factory.php @@ -11,6 +11,7 @@ namespace Carbon; use Closure; +use DateTimeInterface; use ReflectionMethod; /** @@ -289,7 +290,13 @@ public function __call($name, $arguments) return \in_array($parameter->getName(), ['tz', 'timezone'], true); }); - if (\count($tzParameters)) { + if (isset($arguments[0]) && \in_array($name, ['instance', 'make', 'create', 'parse'], true)) { + if ($arguments[0] instanceof DateTimeInterface) { + $settings['innerTimezone'] = $settings['timezone']; + } elseif (\is_string($arguments[0]) && date_parse($arguments[0])['is_localtime']) { + unset($settings['timezone'], $settings['innerTimezone']); + } + } elseif (\count($tzParameters)) { array_splice($arguments, key($tzParameters), 0, [$settings['timezone']]); unset($settings['timezone']); } diff --git a/src/Carbon/Traits/Creator.php b/src/Carbon/Traits/Creator.php index fa7cfc7b1..4f44f1d2a 100644 --- a/src/Carbon/Traits/Creator.php +++ b/src/Carbon/Traits/Creator.php @@ -367,7 +367,7 @@ private static function createNowInstance($tz) */ public static function create($year = 0, $month = 1, $day = 1, $hour = 0, $minute = 0, $second = 0, $tz = null) { - if (\is_string($year) && !is_numeric($year)) { + if (\is_string($year) && !is_numeric($year) || $year instanceof DateTimeInterface) { return static::parse($year, $tz ?: (\is_string($month) || $month instanceof DateTimeZone ? $month : null)); } diff --git a/src/Carbon/Traits/Date.php b/src/Carbon/Traits/Date.php index 07ac0dde9..e7762fa3a 100644 --- a/src/Carbon/Traits/Date.php +++ b/src/Carbon/Traits/Date.php @@ -1584,10 +1584,11 @@ public function setTimezone($value) */ public function shiftTimezone($value) { - $offset = $this->offset; - $date = $this->setTimezone($value); + $dateTimeString = $this->format('Y-m-d H:i:s.u'); - return $date->addRealMicroseconds(($offset - $date->offset) * static::MICROSECONDS_PER_SECOND); + return $this + ->setTimezone($value) + ->modify($dateTimeString); } /** diff --git a/src/Carbon/Traits/Options.php b/src/Carbon/Traits/Options.php index 0a49372c6..5a27d8103 100644 --- a/src/Carbon/Traits/Options.php +++ b/src/Carbon/Traits/Options.php @@ -385,6 +385,10 @@ public function settings(array $settings) $this->locale(...$locales); } + if (isset($settings['innerTimezone'])) { + return $this->setTimezone($settings['innerTimezone']); + } + if (isset($settings['timezone'])) { return $this->shiftTimezone($settings['timezone']); } diff --git a/tests/Carbon/SettersTest.php b/tests/Carbon/SettersTest.php index b49532af5..7eac29be1 100644 --- a/tests/Carbon/SettersTest.php +++ b/tests/Carbon/SettersTest.php @@ -396,6 +396,9 @@ public function testShiftTimezone() $this->assertSame(21600, $d2->getTimestamp() - $d->getTimestamp()); $this->assertSame('America/Toronto', $d2->tzName); $this->assertSame('10:53:12.321654', $d2->format('H:i:s.u')); + + $d = Carbon::parse('2018-03-25 00:53:12.321654 America/Toronto')->shiftTimezone('Europe/Oslo'); + $this->assertSame('2018-03-25 00:53:12.321654 Europe/Oslo', $d->format('Y-m-d H:i:s.u e')); } public function testTimezoneUsingString() diff --git a/tests/CarbonImmutable/SettersTest.php b/tests/CarbonImmutable/SettersTest.php index e273cb6f0..27c278622 100644 --- a/tests/CarbonImmutable/SettersTest.php +++ b/tests/CarbonImmutable/SettersTest.php @@ -272,6 +272,9 @@ public function testShiftTimezone() $this->assertSame(21600, $d2->getTimestamp() - $d->getTimestamp()); $this->assertSame('America/Toronto', $d2->tzName); $this->assertSame('10:53:12', $d2->format('H:i:s')); + + $d = Carbon::parse('2018-03-25 00:53:12.321654 America/Toronto')->shiftTimezone('Europe/Oslo'); + $this->assertSame('2018-03-25 00:53:12.321654 Europe/Oslo', $d->format('Y-m-d H:i:s.u e')); } public function testTimezoneUsingString() diff --git a/tests/CarbonInterval/ToPeriodTest.php b/tests/CarbonInterval/ToPeriodTest.php index 12b79235c..4e676713f 100644 --- a/tests/CarbonInterval/ToPeriodTest.php +++ b/tests/CarbonInterval/ToPeriodTest.php @@ -46,4 +46,14 @@ public function provideToPeriodParameters(): Generator '2018-05-14T17:30:00+00:00/PT30M/2018-05-14T18:00:00+02:00', ]; } + + public function testToDatePeriodWithTimezone(): void + { + $period = CarbonInterval::minutes(30) + ->setTimezone('Asia/Tokyo') + ->toPeriod('2021-08-14 00:00', '2021-08-14 02:00'); + + $this->assertSame('2021-08-14 00:00 Asia/Tokyo', $period->start->format('Y-m-d H:i e')); + $this->assertSame('2021-08-14 02:00 Asia/Tokyo', $period->end->format('Y-m-d H:i e')); + } } diff --git a/tests/CarbonPeriod/CreateTest.php b/tests/CarbonPeriod/CreateTest.php index 2370332ca..2d0c81e48 100644 --- a/tests/CarbonPeriod/CreateTest.php +++ b/tests/CarbonPeriod/CreateTest.php @@ -314,7 +314,11 @@ public function testCreateFromBaseClasses() ); $this->assertSame( - $this->standardizeDates(['2018-04-16', '2018-05-16', '2018-06-16']), + [ + '2018-04-16 00:00:00 -04:00', + '2018-05-16 00:00:00 -04:00', + '2018-06-16 00:00:00 -04:00', + ], $this->standardizeDates($period) ); } diff --git a/tests/CarbonPeriod/SettersTest.php b/tests/CarbonPeriod/SettersTest.php index 512af31b3..264e08bbc 100644 --- a/tests/CarbonPeriod/SettersTest.php +++ b/tests/CarbonPeriod/SettersTest.php @@ -425,4 +425,48 @@ public function testFluentSetters() $this->assertSame(20, $period->getDateInterval()->dayz); $this->assertSame($opt, $period->getOptions()); } + + public function testSetTimezone(): void + { + $period = CarbonPeriod::create( + '2018-03-25 00:00 America/Toronto', + 'PT1H', + '2018-03-25 12:00 Europe/London' + )->setTimezone('Europe/Oslo'); + + $this->assertSame('2018-03-25 06:00 Europe/Oslo', $period->getStartDate()->format('Y-m-d H:i e')); + $this->assertSame('2018-03-25 13:00 Europe/Oslo', $period->getEndDate()->format('Y-m-d H:i e')); + + $period = CarbonPeriod::create( + '2018-03-25 00:00 America/Toronto', + 'PT1H', + 5 + )->setTimezone('Europe/Oslo'); + + $this->assertSame('2018-03-25 06:00 Europe/Oslo', $period->getStartDate()->format('Y-m-d H:i e')); + $this->assertNull($period->getEndDate()); + $this->assertSame('2018-03-25 10:00 Europe/Oslo', $period->calculateEnd()->format('Y-m-d H:i e')); + } + + public function testShiftTimezone(): void + { + $period = CarbonPeriod::create( + '2018-03-25 00:00 America/Toronto', + 'PT1H', + '2018-03-25 12:00 Europe/London' + )->shiftTimezone('Europe/Oslo'); + + $this->assertSame('2018-03-25 00:00 Europe/Oslo', $period->getStartDate()->format('Y-m-d H:i e')); + $this->assertSame('2018-03-25 12:00 Europe/Oslo', $period->getEndDate()->format('Y-m-d H:i e')); + + $period = CarbonPeriod::create( + '2018-03-26 00:00 America/Toronto', + 'PT1H', + 5 + )->shiftTimezone('Europe/Oslo'); + + $this->assertSame('2018-03-26 00:00 Europe/Oslo', $period->getStartDate()->format('Y-m-d H:i e')); + $this->assertNull($period->getEndDate()); + $this->assertSame('2018-03-26 04:00 Europe/Oslo', $period->calculateEnd()->format('Y-m-d H:i e')); + } } diff --git a/tests/Factory/FactoryTest.php b/tests/Factory/FactoryTest.php index b8c60e5aa..2c86b13ce 100644 --- a/tests/Factory/FactoryTest.php +++ b/tests/Factory/FactoryTest.php @@ -107,5 +107,41 @@ public function testFactoryTimezone() $this->assertSame('2020-09-03 00:00:00.000000 America/Toronto', $factory->today()->format('Y-m-d H:i:s.u e')); $this->assertSame('2020-09-04 00:00:00.000000 America/Toronto', $factory->tomorrow()->format('Y-m-d H:i:s.u e')); $this->assertSame('2020-09-04 09:39:04.123456 America/Toronto', $factory->parse('2020-09-04 09:39:04.123456')->format('Y-m-d H:i:s.u e')); + + $factory = new Factory([ + 'timezone' => 'Asia/Shanghai', + ]); + + $baseDate = Carbon::parse('2021-08-01 08:00:00', 'UTC'); + + $date = $factory->createFromTimestamp($baseDate->getTimestamp()); + $this->assertSame('2021-08-01T16:00:00+08:00', $date->format('c')); + + $date = $factory->make('2021-08-01 08:00:00'); + $this->assertSame('2021-08-01T08:00:00+08:00', $date->format('c')); + + $date = $factory->make($baseDate); + $this->assertSame('2021-08-01T16:00:00+08:00', $date->format('c')); + + $date = $factory->create($baseDate); + $this->assertSame('2021-08-01T16:00:00+08:00', $date->format('c')); + + $date = $factory->parse($baseDate); + $this->assertSame('2021-08-01T16:00:00+08:00', $date->format('c')); + + $date = $factory->instance($baseDate); + $this->assertSame('2021-08-01T16:00:00+08:00', $date->format('c')); + + $date = $factory->make('2021-08-01 08:00:00+00:20'); + $this->assertSame('2021-08-01T08:00:00+00:20', $date->format('c')); + + $date = $factory->parse('2021-08-01T08:00:00Z'); + $this->assertSame('2021-08-01T08:00:00+00:00', $date->format('c')); + + $date = $factory->create('2021-08-01 08:00:00 UTC'); + $this->assertSame('2021-08-01T08:00:00+00:00', $date->format('c')); + + $date = $factory->make('2021-08-01 08:00:00 Europe/Paris'); + $this->assertSame('2021-08-01T08:00:00+02:00', $date->format('c')); } }