diff --git a/docs/component/date-time.md b/docs/component/date-time.md index 13c3fb3a..a0bee913 100644 --- a/docs/component/date-time.md +++ b/docs/component/date-time.md @@ -47,7 +47,7 @@ #### `Traits` -- [DateTimeConvenienceMethodsTrait](./../../src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php#L15) +- [DateTimeConvenienceMethodsTrait](./../../src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php#L18) - [TemporalConvenienceMethodsTrait](./../../src/Psl/DateTime/TemporalConvenienceMethodsTrait.php#L16) #### `Enums` diff --git a/src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php b/src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php index 986637a8..b3fdf1ed 100644 --- a/src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php +++ b/src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php @@ -7,6 +7,9 @@ use Psl\Locale\Locale; use Psl\Math; +use function intdiv; +use function min; + /** * @require-implements DateTimeInterface * @@ -362,8 +365,7 @@ public function isLeapYear(): bool /** * Adds the specified years to this date-time object, returning a new instance with the added years. * - * @throws Exception\UnderflowException If adding the years results in an arithmetic underflow. - * @throws Exception\OverflowException If adding the years results in an arithmetic overflow. + * @throws Exception\UnexpectedValueException If adding the years results in an arithmetic issue. * * @psalm-mutation-free */ @@ -375,8 +377,7 @@ public function plusYears(int $years): static /** * Subtracts the specified years from this date-time object, returning a new instance with the subtracted years. * - * @throws Exception\UnderflowException If subtracting the years results in an arithmetic underflow. - * @throws Exception\OverflowException If subtracting the years results in an arithmetic overflow. + * @throws Exception\UnexpectedValueException If subtracting the years results in an arithmetic issue. * * @psalm-mutation-free */ @@ -388,8 +389,7 @@ public function minusYears(int $years): static /** * Adds the specified months to this date-time object, returning a new instance with the added months. * - * @throws Exception\UnderflowException If adding the months results in an arithmetic underflow. - * @throws Exception\OverflowException If adding the months results in an arithmetic overflow. + * @throws Exception\UnexpectedValueException If adding the months results in an arithmetic issue. * * @psalm-mutation-free */ @@ -403,28 +403,29 @@ public function plusMonths(int $months): static return $this->minusMonths(-$months); } - $current_year = $this->getYear(); - $current_month = $this->getMonthEnum(); - $days_to_add = 0; - for ($i = 0; $i < $months; $i++) { - $total_months = $current_month->value + $i; - $target_year = $current_year + Math\div($total_months - 1, MONTHS_PER_YEAR); - $target_month = $total_months % MONTHS_PER_YEAR; - if ($target_month === 0) { - $target_month = 1; - } - - $days_to_add += Month::from($target_month)->getDaysForYear($target_year); + $plus_years = intdiv($months, 12); + $months_left = $months - ($plus_years * 12); + $target_month = $this->getMonth() + $months_left; + + if ($target_month > 12) { + $plus_years++; + $target_month = $target_month - 12; } - return $this->plus(Duration::days($days_to_add)); + return $this->withDate( + $target_year = $this->getYear() + $plus_years, + $target_month = Month::from($target_month)->value, + min([ + $this->getDay(), + Month::from($target_month)->getDaysForYear($target_year) + ]) + ); } /** * Subtracts the specified months from this date-time object, returning a new instance with the subtracted months. * - * @throws Exception\UnderflowException If subtracting the months results in an arithmetic underflow. - * @throws Exception\OverflowException If subtracting the months results in an arithmetic overflow. + * @throws Exception\UnexpectedValueException If subtracting the months results in an arithmetic issue. * * @psalm-mutation-free */ @@ -438,25 +439,23 @@ public function minusMonths(int $months): static return $this->plusMonths(-$months); } - $current_year = $this->getYear(); - $current_month = $this->getMonthEnum(); - $days_to_subtract = 0; - for ($i = 0; $i < $months; $i++) { - // When subtracting, we need to move the current month back before the calculation - $total_months = $current_month->value - $i; - while ($total_months <= 0) { - $total_months += MONTHS_PER_YEAR; // Adjust month to be within 1-12 - $current_year--; // Adjust year when wrapping - } - - $target_month = ($total_months % MONTHS_PER_YEAR) ?: MONTHS_PER_YEAR; - $target_year = $current_year + Math\div($total_months - 1, MONTHS_PER_YEAR); - - // Subtract days of the month we are moving into - $days_to_subtract += Month::from($target_month)->getDaysForYear($target_year); + $minus_years = intdiv($months, 12); + $months_left = $months - ($minus_years * 12); + $target_month = $this->getMonth() - $months_left; + + if ($target_month <= 0) { + $minus_years++; + $target_month = 12 - Math\abs($target_month); } - return $this->minus(Duration::days($days_to_subtract)); + return $this->withDate( + $target_year = $this->getYear() - $minus_years, + $target_month = Month::from($target_month)->value, + min([ + $this->getDay(), + Month::from($target_month)->getDaysForYear($target_year) + ]) + ); } /** diff --git a/tests/unit/DateTime/DateTimeTest.php b/tests/unit/DateTime/DateTimeTest.php index f1d09fa4..b400d543 100644 --- a/tests/unit/DateTime/DateTimeTest.php +++ b/tests/unit/DateTime/DateTimeTest.php @@ -194,6 +194,44 @@ public function testPlusMethods(): void static::assertSame(1, $new->getNanoseconds()); } + public function testPlusMonthsEdgeCases(): void + { + $jan_31th = DateTime::fromParts(Timezone::default(), 2024, Month::January, 31, 14, 0, 0, 0); + $febr_29th = $jan_31th->plusMonths(1); + static::assertSame([2024, 2, 29], $febr_29th->getDate()); + static::assertSame([14, 0, 0, 0], $febr_29th->getTime()); + + $dec_31th = DateTime::fromParts(Timezone::default(), 2023, Month::December, 31, 14, 0, 0, 0); + $march_31th = $dec_31th->plusMonths(3); + static::assertSame([2024, 3, 31], $march_31th->getDate()); + static::assertSame([14, 0, 0, 0], $march_31th->getTime()); + + $april_30th = $march_31th->plusMonths(1); + static::assertSame([2024, 4, 30], $april_30th->getDate()); + static::assertSame([14, 0, 0, 0], $april_30th->getTime()); + + $april_30th_next_year = $april_30th->plusYears(1); + static::assertSame([2025, 4, 30], $april_30th_next_year->getDate()); + static::assertSame([14, 0, 0, 0], $april_30th_next_year->getTime()); + } + + public function testPlusMonthOverflows(): void + { + $jan_31th_2024 = DateTime::fromParts(Timezone::default(), 2024, Month::January, 31, 14, 0, 0, 0); + $previous_month = 1; + for ($i = 1; $i < 24; $i++) { + $res = $jan_31th_2024->plusMonths($i); + + $expected_month = ($previous_month + 1) % 12; + $expected_month = $expected_month === 0 ? 12 : $expected_month; + + static::assertSame($res->getDay(), $res->getMonthEnum()->getDaysForYear($res->getYear())); + static::assertSame($res->getMonth(), $expected_month); + + $previous_month = $expected_month; + } + } + public function testMinusMethods(): void { $datetime = DateTime::fromParts(Timezone::default(), 2024, Month::February, 4, 14, 0, 0, 0); @@ -220,6 +258,57 @@ public function testMinusMethods(): void static::assertSame(999999999, $new->getNanoseconds()); } + public function testMinusMonthsEdgeCases(): void + { + $febr_29th = DateTime::fromParts(Timezone::default(), 2024, Month::February, 29, 14, 0, 0, 0); + $jan_29th = $febr_29th->minusMonths(1); + static::assertSame([2024, 1, 29], $jan_29th->getDate()); + static::assertSame([14, 0, 0, 0], $jan_29th->getTime()); + + $febr_28th_previous_year = $febr_29th->minusYears(1); + static::assertSame([2023, 2, 28], $febr_28th_previous_year->getDate()); + static::assertSame([14, 0, 0, 0], $febr_28th_previous_year->getTime()); + + $febr_29th_previous_leap_year = $febr_29th->minusYears(4); + static::assertSame([2020, 2, 29], $febr_29th_previous_leap_year->getDate()); + static::assertSame([14, 0, 0, 0], $febr_29th_previous_leap_year->getTime()); + + $march_31th = DateTime::fromParts(Timezone::default(), 2024, Month::March, 31, 14, 0, 0, 0); + $dec_31th = $march_31th->minusMonths(3); + static::assertSame([2023, 12, 31], $dec_31th->getDate()); + static::assertSame([14, 0, 0, 0], $dec_31th->getTime()); + + $jan_31th = $march_31th->minusMonths(2); + static::assertSame([2024, 1, 31], $jan_31th->getDate()); + static::assertSame([14, 0, 0, 0], $jan_31th->getTime()); + + $may_31th = DateTime::fromParts(Timezone::default(), 2024, Month::May, 31, 14, 0, 0, 0); + $april_30th = $may_31th->minusMonths(1); + static::assertSame([2024, 4, 30], $april_30th->getDate()); + static::assertSame([14, 0, 0, 0], $april_30th->getTime()); + + $april_30th_previous_year = $april_30th->minusYears(1); + static::assertSame([2023, 4, 30], $april_30th_previous_year->getDate()); + static::assertSame([14, 0, 0, 0], $april_30th_previous_year->getTime()); + } + + public function testMinusMonthOverflows(): void + { + $jan_31th_2024 = DateTime::fromParts(Timezone::default(), 2024, Month::January, 31, 14, 0, 0, 0); + $previous_month = 1; + for ($i = 1; $i < 24; $i++) { + $res = $jan_31th_2024->minusMonths($i); + + $expected_month = $previous_month - 1; + $expected_month = $expected_month === 0 ? 12 : $expected_month; + + static::assertSame($res->getDay(), $res->getMonthEnum()->getDaysForYear($res->getYear())); + static::assertSame($res->getMonth(), $expected_month); + + $previous_month = $expected_month; + } + } + public function testIsLeapYear(): void { $datetime = DateTime::fromParts(Timezone::default(), 2024, Month::February, 4, 14, 0, 0, 0);