diff --git a/docs/en/modifying.md b/docs/en/modifying.md index c5a96a6..5c09976 100644 --- a/docs/en/modifying.md +++ b/docs/en/modifying.md @@ -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: @@ -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 diff --git a/src/Chronos.php b/src/Chronos.php index d1e7d2d..6bed4da 100644 --- a/src/Chronos.php +++ b/src/Chronos.php @@ -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 * diff --git a/tests/TestCase/DateTime/ElapsedTimeAddTest.php b/tests/TestCase/DateTime/ElapsedTimeAddTest.php new file mode 100644 index 0000000..627eb5f --- /dev/null +++ b/tests/TestCase/DateTime/ElapsedTimeAddTest.php @@ -0,0 +1,184 @@ + + * @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()); + } +}