Skip to content

Commit e6e777b

Browse files
authored
Merge pull request #520 from cakephp/dst-timestamp-methods
Add timestamp-based add/sub methods for DST-safe time arithmetic
2 parents 4aa33d9 + b91f885 commit e6e777b

3 files changed

Lines changed: 322 additions & 3 deletions

File tree

docs/en/modifying.md

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ Available add/sub methods:
4242
- `addMinutes()` / `subMinutes()`
4343
- `addSeconds()` / `subSeconds()`
4444

45+
For DST-safe operations that add actual elapsed time (see [DST Considerations](#dst-considerations)):
46+
47+
- `addElapsedHours()` / `subElapsedHours()`
48+
- `addElapsedMinutes()` / `subElapsedMinutes()`
49+
- `addElapsedSeconds()` / `subElapsedSeconds()`
50+
4551
### Month Overflow Handling
4652

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

168174
## DST Considerations
169175

170-
When modifying dates/times across DST (Daylight Savings Time) transitions,
176+
When modifying dates/times across DST (Daylight Saving Time) transitions,
171177
your operations may gain/lose an additional hour resulting in values that
172-
don't add up. You can avoid these issues by first changing your timezone to
173-
UTC, modifying the time, then converting back:
178+
don't add up. Methods like `addHours()`, `addMinutes()`, and `addSeconds()`
179+
add "wall clock" time, which can produce unexpected results during DST
180+
transitions.
181+
182+
### Elapsed Time Methods
183+
184+
For operations that need to add actual elapsed time (not wall clock time),
185+
use the elapsed time variants:
186+
187+
- `addElapsedHours()` / `subElapsedHours()`
188+
- `addElapsedMinutes()` / `subElapsedMinutes()`
189+
- `addElapsedSeconds()` / `subElapsedSeconds()`
190+
191+
These methods manipulate the Unix timestamp directly, ensuring that adding
192+
600 minutes always means exactly 36000 seconds of elapsed time:
193+
194+
```php
195+
// Australia/Melbourne DST ends April 5, 2026 at 3:00 AM
196+
// Clocks go back from 3:00 AM AEDT (+11) to 2:00 AM AEST (+10)
197+
$startOfDay = Chronos::parse('2026-04-05 00:00:00', 'Australia/Melbourne');
198+
199+
// Wall clock addition - adds 10 hours of "clock time"
200+
$wallClock = $startOfDay->addMinutes(600);
201+
// Result: 2026-04-05T10:00:00+10:00
202+
203+
// Elapsed time addition - adds 10 hours of elapsed time
204+
$elapsed = $startOfDay->addElapsedMinutes(600);
205+
// Result: 2026-04-05T09:00:00+10:00
206+
```
207+
208+
The elapsed time methods ensure that `diffInMinutes()` and
209+
`addElapsedMinutes()` are true inverses of each other:
210+
211+
```php
212+
$time = Chronos::parse('2026-04-05 09:00:00', 'Australia/Melbourne');
213+
$startOfDay = $time->startOfDay();
214+
215+
$diff = $time->diffInMinutes($startOfDay); // 600
216+
217+
// Reconstructing the original time works correctly
218+
$reconstructed = $startOfDay->addElapsedMinutes($diff);
219+
// $reconstructed equals $time
220+
```
221+
222+
### Manual UTC Conversion
223+
224+
Alternatively, you can manually convert to UTC, modify, then convert back:
174225

175226
```php
176227
// Additional hour gained

src/Chronos.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,6 +1470,90 @@ public function subSeconds(int $value): static
14701470
return $this->addSeconds(-$value);
14711471
}
14721472

1473+
/**
1474+
* Add hours to the instance using elapsed time.
1475+
*
1476+
* Unlike `addHours()` which uses wall clock time, this method
1477+
* adds actual elapsed time by manipulating the Unix timestamp.
1478+
* This is important when working across DST transitions where
1479+
* wall clock time and elapsed time differ.
1480+
*
1481+
* @param int $value The number of hours to add.
1482+
* @return static
1483+
*/
1484+
public function addElapsedHours(int $value): static
1485+
{
1486+
return $this->setTimestamp($this->getTimestamp() + ($value * 3600));
1487+
}
1488+
1489+
/**
1490+
* Remove hours from the instance using elapsed time.
1491+
*
1492+
* @param int $value The number of hours to remove.
1493+
* @return static
1494+
* @see addElapsedHours()
1495+
*/
1496+
public function subElapsedHours(int $value): static
1497+
{
1498+
return $this->addElapsedHours(-$value);
1499+
}
1500+
1501+
/**
1502+
* Add minutes to the instance using elapsed time.
1503+
*
1504+
* Unlike `addMinutes()` which uses wall clock time, this method
1505+
* adds actual elapsed time by manipulating the Unix timestamp.
1506+
* This is important when working across DST transitions where
1507+
* wall clock time and elapsed time differ.
1508+
*
1509+
* @param int $value The number of minutes to add.
1510+
* @return static
1511+
*/
1512+
public function addElapsedMinutes(int $value): static
1513+
{
1514+
return $this->setTimestamp($this->getTimestamp() + ($value * 60));
1515+
}
1516+
1517+
/**
1518+
* Remove minutes from the instance using elapsed time.
1519+
*
1520+
* @param int $value The number of minutes to remove.
1521+
* @return static
1522+
* @see addElapsedMinutes()
1523+
*/
1524+
public function subElapsedMinutes(int $value): static
1525+
{
1526+
return $this->addElapsedMinutes(-$value);
1527+
}
1528+
1529+
/**
1530+
* Add seconds to the instance using elapsed time.
1531+
*
1532+
* Unlike `addSeconds()` which uses wall clock time, this method
1533+
* adds actual elapsed time by manipulating the Unix timestamp.
1534+
* This is important when working across DST transitions where
1535+
* wall clock time and elapsed time differ.
1536+
*
1537+
* @param int $value The number of seconds to add.
1538+
* @return static
1539+
*/
1540+
public function addElapsedSeconds(int $value): static
1541+
{
1542+
return $this->setTimestamp($this->getTimestamp() + $value);
1543+
}
1544+
1545+
/**
1546+
* Remove seconds from the instance using elapsed time.
1547+
*
1548+
* @param int $value The number of seconds to remove.
1549+
* @return static
1550+
* @see addElapsedSeconds()
1551+
*/
1552+
public function subElapsedSeconds(int $value): static
1553+
{
1554+
return $this->addElapsedSeconds(-$value);
1555+
}
1556+
14731557
/**
14741558
* Sets the time to 00:00:00
14751559
*
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
11+
* @copyright Copyright (c) Brian Nesbitt <brian@nesbot.com>
12+
* @link https://cakephp.org CakePHP(tm) Project
13+
* @license https://www.opensource.org/licenses/mit-license.php MIT License
14+
*/
15+
16+
namespace Cake\Chronos\Test\TestCase\DateTime;
17+
18+
use Cake\Chronos\Chronos;
19+
use Cake\Chronos\Test\TestCase\TestCase;
20+
21+
class ElapsedTimeAddTest extends TestCase
22+
{
23+
public function testAddElapsedSeconds(): void
24+
{
25+
$time = Chronos::parse('2024-01-15 12:00:00', 'UTC');
26+
$result = $time->addElapsedSeconds(30);
27+
$this->assertSame('2024-01-15 12:00:30', $result->format('Y-m-d H:i:s'));
28+
}
29+
30+
public function testAddElapsedSecondsNegative(): void
31+
{
32+
$time = Chronos::parse('2024-01-15 12:00:30', 'UTC');
33+
$result = $time->addElapsedSeconds(-30);
34+
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
35+
}
36+
37+
public function testSubElapsedSeconds(): void
38+
{
39+
$time = Chronos::parse('2024-01-15 12:00:30', 'UTC');
40+
$result = $time->subElapsedSeconds(30);
41+
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
42+
}
43+
44+
public function testAddElapsedMinutes(): void
45+
{
46+
$time = Chronos::parse('2024-01-15 12:00:00', 'UTC');
47+
$result = $time->addElapsedMinutes(30);
48+
$this->assertSame('2024-01-15 12:30:00', $result->format('Y-m-d H:i:s'));
49+
}
50+
51+
public function testAddElapsedMinutesNegative(): void
52+
{
53+
$time = Chronos::parse('2024-01-15 12:30:00', 'UTC');
54+
$result = $time->addElapsedMinutes(-30);
55+
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
56+
}
57+
58+
public function testSubElapsedMinutes(): void
59+
{
60+
$time = Chronos::parse('2024-01-15 12:30:00', 'UTC');
61+
$result = $time->subElapsedMinutes(30);
62+
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
63+
}
64+
65+
public function testAddElapsedHours(): void
66+
{
67+
$time = Chronos::parse('2024-01-15 12:00:00', 'UTC');
68+
$result = $time->addElapsedHours(2);
69+
$this->assertSame('2024-01-15 14:00:00', $result->format('Y-m-d H:i:s'));
70+
}
71+
72+
public function testAddElapsedHoursNegative(): void
73+
{
74+
$time = Chronos::parse('2024-01-15 14:00:00', 'UTC');
75+
$result = $time->addElapsedHours(-2);
76+
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
77+
}
78+
79+
public function testSubElapsedHours(): void
80+
{
81+
$time = Chronos::parse('2024-01-15 14:00:00', 'UTC');
82+
$result = $time->subElapsedHours(2);
83+
$this->assertSame('2024-01-15 12:00:00', $result->format('Y-m-d H:i:s'));
84+
}
85+
86+
/**
87+
* Test DST transition when clocks go BACK (fall back).
88+
* Australia/Melbourne changes out of daylight saving on 5th April 2026
89+
* at 3:00 AM AEDT (+11) -> 2:00 AM AEST (+10)
90+
*/
91+
public function testAddElapsedMinutesAcrossDstFallBack(): void
92+
{
93+
$time = Chronos::parse('2026-04-05 09:00:00', 'Australia/Melbourne');
94+
95+
$this->assertSame('2026-04-05T09:00:00+10:00', $time->toIso8601String());
96+
$this->assertSame('2026-04-05T00:00:00+11:00', $time->startOfDay()->toIso8601String());
97+
98+
$diff = $time->diffInMinutes($time->startOfDay());
99+
$this->assertSame(600, $diff);
100+
101+
// Using elapsed time should correctly account for DST
102+
$result = $time->startOfDay()->addElapsedMinutes(600);
103+
$this->assertSame('2026-04-05T09:00:00+10:00', $result->toIso8601String());
104+
}
105+
106+
/**
107+
* Test DST transition when clocks go FORWARD (spring forward).
108+
* America/New_York springs forward on 2nd Sunday of March 2025
109+
* at 2:00 AM EST (-05) -> 3:00 AM EDT (-04)
110+
*/
111+
public function testAddElapsedMinutesAcrossDstSpringForward(): void
112+
{
113+
// March 9, 2025 is the 2nd Sunday of March (DST starts)
114+
$beforeDst = Chronos::parse('2025-03-09 01:00:00', 'America/New_York');
115+
$this->assertSame('-05:00', $beforeDst->format('P'));
116+
117+
// Add 2 hours (120 minutes) using elapsed time
118+
// Wall clock would show 3:00 AM (skipping 2:00-3:00)
119+
$result = $beforeDst->addElapsedMinutes(120);
120+
121+
// Should be 04:00 AM EDT (not 03:00 AM)
122+
$this->assertSame('2025-03-09T04:00:00-04:00', $result->toIso8601String());
123+
}
124+
125+
/**
126+
* Test that addMinutes and addElapsedMinutes differ during DST
127+
*/
128+
public function testAddMinutesVsAddElapsedMinutesDuringDst(): void
129+
{
130+
// Australia/Melbourne DST ends April 5, 2026 at 3am
131+
$startOfDay = Chronos::parse('2026-04-05 00:00:00', 'Australia/Melbourne');
132+
133+
// Wall clock addition (regular addMinutes)
134+
$wallClock = $startOfDay->addMinutes(600);
135+
136+
// Elapsed time addition
137+
$elapsed = $startOfDay->addElapsedMinutes(600);
138+
139+
// These should differ by 1 hour due to DST transition
140+
$this->assertSame('2026-04-05T10:00:00+10:00', $wallClock->toIso8601String());
141+
$this->assertSame('2026-04-05T09:00:00+10:00', $elapsed->toIso8601String());
142+
}
143+
144+
/**
145+
* Test addElapsedHours across DST
146+
*/
147+
public function testAddElapsedHoursAcrossDst(): void
148+
{
149+
$startOfDay = Chronos::parse('2026-04-05 00:00:00', 'Australia/Melbourne');
150+
151+
$result = $startOfDay->addElapsedHours(10);
152+
153+
// 10 actual hours from midnight should be 09:00 (since we gain an hour at 3am)
154+
$this->assertSame('2026-04-05T09:00:00+10:00', $result->toIso8601String());
155+
}
156+
157+
/**
158+
* Test addElapsedSeconds across DST
159+
*/
160+
public function testAddElapsedSecondsAcrossDst(): void
161+
{
162+
$startOfDay = Chronos::parse('2026-04-05 00:00:00', 'Australia/Melbourne');
163+
164+
// 10 hours in seconds = 36000
165+
$result = $startOfDay->addElapsedSeconds(36000);
166+
167+
$this->assertSame('2026-04-05T09:00:00+10:00', $result->toIso8601String());
168+
}
169+
170+
/**
171+
* Test that diffInMinutes and addElapsedMinutes are inverses
172+
*/
173+
public function testDiffInMinutesIsInverseOfAddElapsedMinutes(): void
174+
{
175+
$time = Chronos::parse('2026-04-05 09:00:00', 'Australia/Melbourne');
176+
$startOfDay = $time->startOfDay();
177+
178+
$diff = $time->diffInMinutes($startOfDay);
179+
180+
$reconstructed = $startOfDay->addElapsedMinutes($diff);
181+
182+
$this->assertSame($time->toIso8601String(), $reconstructed->toIso8601String());
183+
}
184+
}

0 commit comments

Comments
 (0)