From 95c416685ab53f3cc33d6b4a1449ac8e68fd6340 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 23 Sep 2026 07:05:08 +0200 Subject: [PATCH] Carry a numeric rounding precision out of any unit, not just seconds --- src/Carbon/Traits/Rounding.php | 42 ++++++++++++++++++++++------------ tests/Carbon/RoundTest.php | 23 +++++++++++++++++++ 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/Carbon/Traits/Rounding.php b/src/Carbon/Traits/Rounding.php index 1ffc19a..407d12a 100644 --- a/src/Carbon/Traits/Rounding.php +++ b/src/Carbon/Traits/Rounding.php @@ -70,21 +70,35 @@ 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; + // How many of each unit make up the next one up. Only the steps with a + // fixed size are listed: a month holds a varying number of days, so a + // day precision is never carried into a month. + $unitsPerNextUnit = [ + // @call roundUnit + 'microsecond' => [static::MICROSECONDS_PER_SECOND, 'second'], + // @call roundUnit + 'second' => [static::SECONDS_PER_MINUTE, 'minute'], + // @call roundUnit + 'minute' => [static::MINUTES_PER_HOUR, 'hour'], + // @call roundUnit + 'hour' => [static::HOURS_PER_DAY, 'day'], + // @call roundUnit + 'month' => [static::MONTHS_PER_YEAR, 'year'], + ]; - 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; + // A numeric precision counts units of $normalizedUnit, and that unit has + // a range it cannot leave, so a precision spanning the next unit up has + // to be re-targeted there, the same way "week" becomes "day" above. + if (\is_int($precision) || \is_float($precision)) { + while (isset($unitsPerNextUnit[$normalizedUnit])) { + [$span, $nextUnit] = $unitsPerNextUnit[$normalizedUnit]; + + if ($precision < $span) { + break; + } + + $precision /= $span; + $normalizedUnit = $nextUnit; } } diff --git a/tests/Carbon/RoundTest.php b/tests/Carbon/RoundTest.php index 1623d56..3551bd8 100644 --- a/tests/Carbon/RoundTest.php +++ b/tests/Carbon/RoundTest.php @@ -237,4 +237,27 @@ class RoundTest extends AbstractTestCase // 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); } + + public function testNumericPrecisionCarryFromAnyUnit(): void + { + $dt = Carbon::parse('2026-01-01 14:21:03'); + + // A precision counting minutes spans a whole day just as one counting + // seconds does, so it has to carry the same way. + $this->assertCarbon($dt->copy()->roundMinutes(24 * 60), 2026, 1, 2, 0, 0, 0, 0); + $this->assertTrue($dt->copy()->roundMinutes(24 * 60)->eq($dt->copy()->round('day'))); + + $this->assertCarbon($dt->copy()->roundHours(24), 2026, 1, 2, 0, 0, 0, 0); + $this->assertTrue($dt->copy()->roundHours(24)->eq($dt->copy()->round('day'))); + + $this->assertCarbon($dt->copy()->roundMinutes(60), 2026, 1, 1, 14, 0, 0, 0); + $this->assertTrue($dt->copy()->roundMinutes(60)->eq($dt->copy()->round('hour'))); + + $this->assertCarbon(Carbon::parse('2026-08-20')->roundMonths(12), 2027, 1, 1, 0, 0, 0, 0); + $this->assertTrue(Carbon::parse('2026-08-20')->roundMonths(12)->eq(Carbon::parse('2026-08-20')->round('year'))); + + // A precision that stays inside its own unit is untouched. + $this->assertCarbon($dt->copy()->roundMinutes(30), 2026, 1, 1, 14, 30, 0, 0); + $this->assertCarbon($dt->copy()->roundSeconds(30), 2026, 1, 1, 14, 21, 0, 0); + } }