Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions docs/en/modifying.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ Available add/sub methods:
- `addMinutes()` / `subMinutes()`
- `addSeconds()` / `subSeconds()`

For DST-safe operations that add actual elapsed time (see [DST Considerations](#dst-considerations)):

- `addElapsedHours()` / `subElapsedHours()`
- `addElapsedMinutes()` / `subElapsedMinutes()`
- `addElapsedSeconds()` / `subElapsedSeconds()`

### Month Overflow Handling

By default, adding months will clamp the day if it would overflow:
Expand Down Expand Up @@ -167,10 +173,55 @@ information and you need to assign the correct timezone.

## DST Considerations

When modifying dates/times across DST (Daylight Savings Time) transitions,
When modifying dates/times across DST (Daylight Saving Time) transitions,
your operations may gain/lose an additional hour resulting in values that
don't add up. You can avoid these issues by first changing your timezone to
UTC, modifying the time, then converting back:
don't add up. Methods like `addHours()`, `addMinutes()`, and `addSeconds()`
add "wall clock" time, which can produce unexpected results during DST
transitions.

### Elapsed Time Methods

For operations that need to add actual elapsed time (not wall clock time),
use the elapsed time variants:

- `addElapsedHours()` / `subElapsedHours()`
- `addElapsedMinutes()` / `subElapsedMinutes()`
- `addElapsedSeconds()` / `subElapsedSeconds()`

These methods manipulate the Unix timestamp directly, ensuring that adding
600 minutes always means exactly 36000 seconds of elapsed time:

```php
// Australia/Melbourne DST ends April 5, 2026 at 3:00 AM
// Clocks go back from 3:00 AM AEDT (+11) to 2:00 AM AEST (+10)
$startOfDay = Chronos::parse('2026-04-05 00:00:00', 'Australia/Melbourne');

// Wall clock addition - adds 10 hours of "clock time"
$wallClock = $startOfDay->addMinutes(600);
// Result: 2026-04-05T10:00:00+10:00

// Elapsed time addition - adds 10 hours of elapsed time
$elapsed = $startOfDay->addElapsedMinutes(600);
// Result: 2026-04-05T09:00:00+10:00
```

The elapsed time methods ensure that `diffInMinutes()` and
`addElapsedMinutes()` are true inverses of each other:

```php
$time = Chronos::parse('2026-04-05 09:00:00', 'Australia/Melbourne');
$startOfDay = $time->startOfDay();

$diff = $time->diffInMinutes($startOfDay); // 600

// Reconstructing the original time works correctly
$reconstructed = $startOfDay->addElapsedMinutes($diff);
// $reconstructed equals $time
```

### Manual UTC Conversion

Alternatively, you can manually convert to UTC, modify, then convert back:

```php
// Additional hour gained
Expand Down
84 changes: 84 additions & 0 deletions src/Chronos.php
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,90 @@ public function subSeconds(int $value): static
return $this->addSeconds(-$value);
}

/**
* Add hours to the instance using elapsed time.
*
* Unlike `addHours()` which uses wall clock time, this method
* adds actual elapsed time by manipulating the Unix timestamp.
* This is important when working across DST transitions where
* wall clock time and elapsed time differ.
*
* @param int $value The number of hours to add.
* @return static
*/
public function addElapsedHours(int $value): static
{
return $this->setTimestamp($this->getTimestamp() + ($value * 3600));
}

/**
* Remove hours from the instance using elapsed time.
*
* @param int $value The number of hours to remove.
* @return static
* @see addElapsedHours()
*/
public function subElapsedHours(int $value): static
{
return $this->addElapsedHours(-$value);
}

/**
* Add minutes to the instance using elapsed time.
*
* Unlike `addMinutes()` which uses wall clock time, this method
* adds actual elapsed time by manipulating the Unix timestamp.
* This is important when working across DST transitions where
* wall clock time and elapsed time differ.
*
* @param int $value The number of minutes to add.
* @return static
*/
public function addElapsedMinutes(int $value): static
{
return $this->setTimestamp($this->getTimestamp() + ($value * 60));
}

/**
* Remove minutes from the instance using elapsed time.
*
* @param int $value The number of minutes to remove.
* @return static
* @see addElapsedMinutes()
*/
public function subElapsedMinutes(int $value): static
{
return $this->addElapsedMinutes(-$value);
}

/**
* Add seconds to the instance using elapsed time.
*
* Unlike `addSeconds()` which uses wall clock time, this method
* adds actual elapsed time by manipulating the Unix timestamp.
* This is important when working across DST transitions where
* wall clock time and elapsed time differ.
*
* @param int $value The number of seconds to add.
* @return static
*/
public function addElapsedSeconds(int $value): static
{
return $this->setTimestamp($this->getTimestamp() + $value);
}

/**
* Remove seconds from the instance using elapsed time.
*
* @param int $value The number of seconds to remove.
* @return static
* @see addElapsedSeconds()
*/
public function subElapsedSeconds(int $value): static
{
return $this->addElapsedSeconds(-$value);
}

/**
* Sets the time to 00:00:00
*
Expand Down
184 changes: 184 additions & 0 deletions tests/TestCase/DateTime/ElapsedTimeAddTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);

/**
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @copyright Copyright (c) Brian Nesbitt <brian@nesbot.com>
* @link https://cakephp.org CakePHP(tm) Project
* @license https://www.opensource.org/licenses/mit-license.php MIT License
*/

namespace Cake\Chronos\Test\TestCase\DateTime;

use Cake\Chronos\Chronos;
use Cake\Chronos\Test\TestCase\TestCase;

class ElapsedTimeAddTest extends TestCase
{
public function testAddElapsedSeconds(): void
{
$time = Chronos::parse('2024-01-15 12:00:00', 'UTC');
$result = $time->addElapsedSeconds(30);
$this->assertSame('2024-01-15 12:00:30', $result->format('Y-m-d H:i:s'));
}

public function testAddElapsedSecondsNegative(): void
{
$time = Chronos::parse('2024-01-15 12:00:30', 'UTC');
$result = $time->addElapsedSeconds(-30);
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
}

public function testSubElapsedSeconds(): void
{
$time = Chronos::parse('2024-01-15 12:00:30', 'UTC');
$result = $time->subElapsedSeconds(30);
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
}

public function testAddElapsedMinutes(): void
{
$time = Chronos::parse('2024-01-15 12:00:00', 'UTC');
$result = $time->addElapsedMinutes(30);
$this->assertSame('2024-01-15 12:30:00', $result->format('Y-m-d H:i:s'));
}

public function testAddElapsedMinutesNegative(): void
{
$time = Chronos::parse('2024-01-15 12:30:00', 'UTC');
$result = $time->addElapsedMinutes(-30);
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
}

public function testSubElapsedMinutes(): void
{
$time = Chronos::parse('2024-01-15 12:30:00', 'UTC');
$result = $time->subElapsedMinutes(30);
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
}

public function testAddElapsedHours(): void
{
$time = Chronos::parse('2024-01-15 12:00:00', 'UTC');
$result = $time->addElapsedHours(2);
$this->assertSame('2024-01-15 14:00:00', $result->format('Y-m-d H:i:s'));
}

public function testAddElapsedHoursNegative(): void
{
$time = Chronos::parse('2024-01-15 14:00:00', 'UTC');
$result = $time->addElapsedHours(-2);
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
}

public function testSubElapsedHours(): void
{
$time = Chronos::parse('2024-01-15 14:00:00', 'UTC');
$result = $time->subElapsedHours(2);
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
}

/**
* Test DST transition when clocks go BACK (fall back).
* Australia/Melbourne changes out of daylight saving on 5th April 2026
* at 3:00 AM AEDT (+11) -> 2:00 AM AEST (+10)
*/
public function testAddElapsedMinutesAcrossDstFallBack(): void
{
$time = Chronos::parse('2026-04-05 09:00:00', 'Australia/Melbourne');

$this->assertSame('2026-04-05T09:00:00+10:00', $time->toIso8601String());
$this->assertSame('2026-04-05T00:00:00+11:00', $time->startOfDay()->toIso8601String());

$diff = $time->diffInMinutes($time->startOfDay());
$this->assertSame(600, $diff);

// Using elapsed time should correctly account for DST
$result = $time->startOfDay()->addElapsedMinutes(600);
$this->assertSame('2026-04-05T09:00:00+10:00', $result->toIso8601String());
}

/**
* Test DST transition when clocks go FORWARD (spring forward).
* America/New_York springs forward on 2nd Sunday of March 2025
* at 2:00 AM EST (-05) -> 3:00 AM EDT (-04)
*/
public function testAddElapsedMinutesAcrossDstSpringForward(): void
{
// March 9, 2025 is the 2nd Sunday of March (DST starts)
$beforeDst = Chronos::parse('2025-03-09 01:00:00', 'America/New_York');
$this->assertSame('-05:00', $beforeDst->format('P'));

// Add 2 hours (120 minutes) using elapsed time
// Wall clock would show 3:00 AM (skipping 2:00-3:00)
$result = $beforeDst->addElapsedMinutes(120);

// Should be 04:00 AM EDT (not 03:00 AM)
$this->assertSame('2025-03-09T04:00:00-04:00', $result->toIso8601String());
}

/**
* Test that addMinutes and addElapsedMinutes differ during DST
*/
public function testAddMinutesVsAddElapsedMinutesDuringDst(): void
{
// Australia/Melbourne DST ends April 5, 2026 at 3am
$startOfDay = Chronos::parse('2026-04-05 00:00:00', 'Australia/Melbourne');

// Wall clock addition (regular addMinutes)
$wallClock = $startOfDay->addMinutes(600);

// Elapsed time addition
$elapsed = $startOfDay->addElapsedMinutes(600);

// These should differ by 1 hour due to DST transition
$this->assertSame('2026-04-05T10:00:00+10:00', $wallClock->toIso8601String());
$this->assertSame('2026-04-05T09:00:00+10:00', $elapsed->toIso8601String());
}

/**
* Test addElapsedHours across DST
*/
public function testAddElapsedHoursAcrossDst(): void
{
$startOfDay = Chronos::parse('2026-04-05 00:00:00', 'Australia/Melbourne');

$result = $startOfDay->addElapsedHours(10);

// 10 actual hours from midnight should be 09:00 (since we gain an hour at 3am)
$this->assertSame('2026-04-05T09:00:00+10:00', $result->toIso8601String());
}

/**
* Test addElapsedSeconds across DST
*/
public function testAddElapsedSecondsAcrossDst(): void
{
$startOfDay = Chronos::parse('2026-04-05 00:00:00', 'Australia/Melbourne');

// 10 hours in seconds = 36000
$result = $startOfDay->addElapsedSeconds(36000);

$this->assertSame('2026-04-05T09:00:00+10:00', $result->toIso8601String());
}

/**
* Test that diffInMinutes and addElapsedMinutes are inverses
*/
public function testDiffInMinutesIsInverseOfAddElapsedMinutes(): void
{
$time = Chronos::parse('2026-04-05 09:00:00', 'Australia/Melbourne');
$startOfDay = $time->startOfDay();

$diff = $time->diffInMinutes($startOfDay);

$reconstructed = $startOfDay->addElapsedMinutes($diff);

$this->assertSame($time->toIso8601String(), $reconstructed->toIso8601String());
}
}
Loading