From 5192615f4e274f3ddd7846d8118b50fd0723d239 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 04:29:29 +0200 Subject: [PATCH] fix: carry numeric round/floor/ceil precision >= 60s into minute/hour/day A numeric $precision passed to round()/floor()/ceil() is always resolved to the "second" unit. Once the precision reaches or exceeds 60 (e.g. round(3600) meaning "nearest hour"), the rounding stayed confined to the second field (range 0-59) and never carried into minute/hour/day, unlike the equivalent string form (round('hour')). Re-target the unit to minute/hour/day once the numeric precision reaches that unit's own span in seconds, rescaling the precision accordingly, mirroring the existing week -> day re-targeting already in roundUnit(). Sub-minute numeric precisions are unaffected. --- src/Carbon/Traits/Rounding.php | 18 ++++++++++++++++++ tests/Carbon/RoundTest.php | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/Carbon/Traits/Rounding.php b/src/Carbon/Traits/Rounding.php index 4239c66..1ffc19a 100644 --- a/src/Carbon/Traits/Rounding.php +++ b/src/Carbon/Traits/Rounding.php @@ -70,6 +70,24 @@ trait Rounding $precision *= $factor; + // A numeric precision is in seconds, so once it spans the next coarser + // unit it must be re-targeted there, like "week" -> "day" above. + if ($normalizedUnit === 'second' && (\is_int($precision) || \is_float($precision))) { + $secondsPerHour = static::MINUTES_PER_HOUR * static::SECONDS_PER_MINUTE; + $secondsPerDay = static::HOURS_PER_DAY * $secondsPerHour; + + if ($precision >= $secondsPerDay) { + $normalizedUnit = 'day'; + $precision /= $secondsPerDay; + } elseif ($precision >= $secondsPerHour) { + $normalizedUnit = 'hour'; + $precision /= $secondsPerHour; + } elseif ($precision >= static::SECONDS_PER_MINUTE) { + $normalizedUnit = 'minute'; + $precision /= static::SECONDS_PER_MINUTE; + } + } + if (!isset($ranges[$normalizedUnit])) { throw new UnknownUnitException($unit); } diff --git a/tests/Carbon/RoundTest.php b/tests/Carbon/RoundTest.php index cd661a3..1623d56 100644 --- a/tests/Carbon/RoundTest.php +++ b/tests/Carbon/RoundTest.php @@ -205,4 +205,36 @@ class RoundTest extends AbstractTestCase Carbon::now()->roundUnit('foobar'); } + + public function testRoundWithNumericPrecisionCarry() + { + // A numeric precision >= 60s must carry, like the equivalent string unit. + $dt = Carbon::create(2026, 1, 1, 14, 21, 37); + + $this->assertCarbon($dt->copy()->round(3600), 2026, 1, 1, 14, 0, 0, 0); + $this->assertTrue($dt->copy()->round(3600)->eq($dt->copy()->round('hour'))); + + $this->assertCarbon($dt->copy()->round(120), 2026, 1, 1, 14, 22, 0, 0); + $this->assertTrue($dt->copy()->round(120)->eq($dt->copy()->round('2 minutes'))); + + $this->assertCarbon($dt->copy()->round(86400), 2026, 1, 2, 0, 0, 0, 0); + $this->assertTrue($dt->copy()->round(86400)->eq($dt->copy()->round('day'))); + + // floor() with numeric precision must also carry, not just clamp within the minute. + $almostNextHour = Carbon::create(2026, 1, 1, 14, 59, 59); + $this->assertCarbon($almostNextHour->copy()->floor(3600), 2026, 1, 1, 14, 0, 0, 0); + $this->assertTrue($almostNextHour->copy()->floor(3600)->eq($almostNextHour->copy()->floor('hour'))); + + // ceil() with numeric precision must carry across the hour boundary. + $halfPastHour = Carbon::create(2026, 1, 1, 14, 30, 0); + $this->assertCarbon($halfPastHour->copy()->ceil(3600), 2026, 1, 1, 15, 0, 0, 0); + $this->assertTrue($halfPastHour->copy()->ceil(3600)->eq($halfPastHour->copy()->ceil('hour'))); + + // Carry across a month boundary via a day-level numeric precision. + $monthEnd = Carbon::create(2026, 1, 31, 20, 0, 0); + $this->assertCarbon($monthEnd->copy()->round(86400), 2026, 2, 1, 0, 0, 0, 0); + + // Sub-minute numeric precision must keep rounding within the second field, unchanged. + $this->assertCarbon($dt->copy()->round(30), 2026, 1, 1, 14, 21, 30, 0); + } }