Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
55 changes: 53 additions & 2 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)):

- `addHoursWithTimestamp()` / `subHoursWithTimestamp()`
- `addMinutesWithTimestamp()` / `subMinutesWithTimestamp()`
- `addSecondsWithTimestamp()` / `subSecondsWithTimestamp()`

### Month Overflow Handling

By default, adding months will clamp the day if it would overflow:
Expand Down Expand Up @@ -169,8 +175,53 @@ information and you need to assign the correct timezone.

When modifying dates/times across DST (Daylight Savings Time) transitions,
Comment thread
dereuromark marked this conversation as resolved.
Outdated
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.

### Timestamp-Based Methods

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

- `addHoursWithTimestamp()` / `subHoursWithTimestamp()`
- `addMinutesWithTimestamp()` / `subMinutesWithTimestamp()`
- `addSecondsWithTimestamp()` / `subSecondsWithTimestamp()`

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

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

The timestamp-based methods ensure that `diffInMinutes()` and
`addMinutesWithTimestamp()` 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->addMinutesWithTimestamp($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 timestamp arithmetic.
*
* 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 addHoursWithTimestamp(int $value): static
{
return $this->setTimestamp($this->getTimestamp() + ($value * 3600));
}

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

/**
* Add minutes to the instance using timestamp arithmetic.
*
* 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 addMinutesWithTimestamp(int $value): static
{
return $this->setTimestamp($this->getTimestamp() + ($value * 60));
}

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

/**
* Add seconds to the instance using timestamp arithmetic.
*
* 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 addSecondsWithTimestamp(int $value): static
{
return $this->setTimestamp($this->getTimestamp() + $value);
}

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

/**
* Sets the time to 00:00:00
*
Expand Down
184 changes: 184 additions & 0 deletions tests/TestCase/DateTime/TimestampAddTest.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 TimestampAddTest extends TestCase
{
public function testAddSecondsWithTimestamp(): void
{
$time = Chronos::parse('2024-01-15 12:00:00', 'UTC');
$result = $time->addSecondsWithTimestamp(30);
$this->assertSame('2024-01-15 12:00:30', $result->format('Y-m-d H:i:s'));
}

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

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

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

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

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

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

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

public function testSubHoursWithTimestamp(): void
{
$time = Chronos::parse('2024-01-15 14:00:00', 'UTC');
$result = $time->subHoursWithTimestamp(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 savings on 5th April 2026
Comment thread
dereuromark marked this conversation as resolved.
Outdated
* at 3:00 AM AEDT (+11) -> 2:00 AM AEST (+10)
*/
public function testAddMinutesWithTimestampAcrossDstFallBack(): 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 timestamp arithmetic should correctly account for DST
$result = $time->startOfDay()->addMinutesWithTimestamp(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 testAddMinutesWithTimestampAcrossDstSpringForward(): 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 timestamp arithmetic
// Wall clock would show 3:00 AM (skipping 2:00-3:00)
$result = $beforeDst->addMinutesWithTimestamp(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 addMinutesWithTimestamp differ during DST
*/
public function testAddMinutesVsAddMinutesWithTimestampDuringDst(): 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);

// Timestamp addition
$elapsed = $startOfDay->addMinutesWithTimestamp(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 addHoursWithTimestamp across DST
*/
public function testAddHoursWithTimestampAcrossDst(): void
{
$startOfDay = Chronos::parse('2026-04-05 00:00:00', 'Australia/Melbourne');

$result = $startOfDay->addHoursWithTimestamp(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 addSecondsWithTimestamp across DST
*/
public function testAddSecondsWithTimestampAcrossDst(): void
{
$startOfDay = Chronos::parse('2026-04-05 00:00:00', 'Australia/Melbourne');

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

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

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

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

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

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