Skip to content

Commit e67c406

Browse files
committed
DateTime: constructor and modify() correctly handle the relative time even if the daylight saving time is changed
1 parent b8e4dc4 commit e67c406

File tree

2 files changed

+93
-4
lines changed

2 files changed

+93
-4
lines changed

src/Utils/DateTime.php

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,13 @@ public static function createFromFormat(
110110

111111
public function __construct(string $datetime = 'now', ?\DateTimeZone $timezone = null)
112112
{
113-
parent::__construct($datetime, $timezone);
114-
$this->handleErrors($datetime);
113+
$this->apply($datetime, $timezone, true);
115114
}
116115

117116

118117
public function modify(string $modifier): static
119118
{
120-
parent::modify($modifier) && $this->handleErrors($modifier);
119+
$this->apply($modifier);
121120
return $this;
122121
}
123122

@@ -155,6 +154,34 @@ public static function relativeToSeconds(string $relativeTime): int
155154
}
156155

157156

157+
private function apply(string $datetime, $timezone = null, bool $ctr = false): void
158+
{
159+
$relPart = '';
160+
$absPart = preg_replace_callback(
161+
'/[+-]?\s*\d+\s+((microsecond|millisecond|[mµu]sec)s?|[mµ]s|sec(ond)?s?|min(ute)?s?|hours?)\b/iu',
162+
function ($m) use (&$relPart) {
163+
$relPart .= $m[0] . ' ';
164+
return '';
165+
},
166+
$datetime,
167+
);
168+
169+
if ($ctr) {
170+
parent::__construct($absPart, $timezone);
171+
$this->handleErrors($datetime);
172+
} elseif (trim($absPart)) {
173+
parent::modify($absPart) && $this->handleErrors($datetime);
174+
}
175+
176+
if ($relPart) {
177+
$timezone ??= $this->getTimezone();
178+
$this->setTimezone(new \DateTimeZone('UTC'));
179+
parent::modify($relPart) && $this->handleErrors($datetime);
180+
$this->setTimezone($timezone);
181+
}
182+
}
183+
184+
158185
/**
159186
* Returns JSON representation in ISO 8601 (used by JavaScript).
160187
*/

tests/Utils/DateTime.modify.phpt

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ require __DIR__ . '/../bootstrap.php';
1414
date_default_timezone_set('Europe/Prague');
1515

1616

17-
test('Basic operations', function () {
17+
test('Basic operations (no DST involved)', function () {
1818
$base = new DateTime('2024-07-15 10:00:00'); // Summer time (CEST)
1919

2020
$dt = clone $base;
@@ -35,6 +35,68 @@ test('Basic operations', function () {
3535
});
3636

3737

38+
test('Spring DST transition (2025-03-30 02:00 -> 03:00)', function () {
39+
$startSpring = new DateTime('2025-03-30 01:45:00'); // Before the jump (CET +01:00)
40+
41+
// Modification ending BEFORE the jump
42+
$dt = clone $startSpring;
43+
$dt->modify('+10 minutes');
44+
Assert::same('2025-03-30 01:55:00 CET (+01:00)', $dt->format('Y-m-d H:i:s T (P)'), '+10 min (ends before jump)');
45+
46+
// Modification crossing the jump (duration logic thanks to Nette fix)
47+
$dt = clone $startSpring;
48+
$dt->modify('+30 minutes'); // 01:45 CET + 30 min duration = 01:15 UTC = 03:15 CEST
49+
Assert::same('2025-03-30 03:15:00 CEST (+02:00)', $dt->format('Y-m-d H:i:s T (P)'), '+30 min (crosses jump)');
50+
51+
$dt = clone $startSpring;
52+
$dt->modify('+90 minutes'); // 01:45 CET + 90 min duration = 02:15 UTC = 04:15 CEST (Key test!)
53+
Assert::same('2025-03-30 04:15:00 CEST (+02:00)', $dt->format('Y-m-d H:i:s T (P)'), '+90 min (crosses jump)');
54+
55+
// Adding a day across the jump (day has only 23 hours)
56+
$dt = clone $startSpring;
57+
$dt->modify('+1 day');
58+
Assert::same('2025-03-31 01:45:00 CEST (+02:00)', $dt->format('Y-m-d H:i:s T (P)'), '+1 day');
59+
60+
// Combination of day + hours across the jump
61+
$dt = clone $startSpring;
62+
$dt->modify('+1 day +1 hour');
63+
Assert::same('2025-03-31 02:45:00 CEST (+02:00)', $dt->format('Y-m-d H:i:s T (P)'), '+1 day + 1 hour');
64+
65+
$dt = clone $startSpring;
66+
$dt->modify('+2 hours'); // 01:45 CET + 2h duration = 02:45 UTC = 04:45 CEST
67+
Assert::same('2025-03-30 04:45:00 CEST (+02:00)', $dt->format('Y-m-d H:i:s T (P)'), '+2 hours (crosses jump)');
68+
});
69+
70+
71+
test('Autumn DST transition (2024-10-27 03:00 -> 02:00)', function () {
72+
$startAutumn = new DateTime('2024-10-27 01:45:00'); // Before the fallback (CEST +02:00)
73+
74+
// Modification ending BEFORE the fallback (still CEST)
75+
$dt = clone $startAutumn;
76+
$dt->modify('+30 minutes');
77+
Assert::same('2024-10-27 02:15:00 CEST (+02:00)', $dt->format('Y-m-d H:i:s T (P)'), '+30 min (ends before fallback)');
78+
79+
// Modification crossing the fallback (lands in the second 2:xx hour - CET)
80+
$dt = clone $startAutumn;
81+
$dt->modify('+90 minutes'); // 01:45 CEST + 90 min duration = 01:15 UTC = 02:15 CET
82+
Assert::same('2024-10-27 02:15:00 CET (+01:00)', $dt->format('Y-m-d H:i:s T (P)'), '+90 min (crosses fallback, lands in CET)');
83+
84+
$dt = clone $startAutumn;
85+
$dt->modify('+1 hour + 30 minutes'); // Same as +90 minutes
86+
Assert::same('2024-10-27 02:15:00 CET (+01:00)', $dt->format('Y-m-d H:i:s T (P)'), '+1 hour + 30 minutes (crosses fallback)');
87+
88+
// Adding a day across the fallback (day has 25 hours)
89+
$dt = clone $startAutumn;
90+
$dt->modify('+1 day');
91+
Assert::same('2024-10-28 01:45:00 CET (+01:00)', $dt->format('Y-m-d H:i:s T (P)'), '+1 day');
92+
93+
// Combination of day + hours across the fallback
94+
$dt = clone $startAutumn;
95+
$dt->modify('+1 day +2 hours');
96+
Assert::same('2024-10-28 03:45:00 CET (+01:00)', $dt->format('Y-m-d H:i:s T (P)'), '+1 day + 2 hours');
97+
});
98+
99+
38100
test('Complex and varied format strings', function () {
39101
$dt = new DateTime('2024-04-10 12:00:00'); // CEST
40102
// Expected: -2m -> 2024-02-10 12:00 CET | +7d -> 2024-02-17 12:00 CET | +23h 59m 59s -> 2024-02-18 11:59:59 CET

0 commit comments

Comments
 (0)