Skip to content

Commit

Permalink
feat(DateTime) Provide a human-like months substract and add system (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
veewee committed Jun 10, 2024
1 parent 99a1dd6 commit ae3bf02
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 38 deletions.
80 changes: 42 additions & 38 deletions src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,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
*/
Expand All @@ -375,8 +374,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
*/
Expand All @@ -388,10 +386,11 @@ 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
*
* @psalm-suppress MissingThrowsDocblock - The Math exceptions from Math\div do not result in any error.
*/
public function plusMonths(int $months): static
{
Expand All @@ -403,30 +402,35 @@ 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 = Math\div($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));
$target_month_enum = Month::from($target_month);

return $this->withDate(
$target_year = $this->getYear() + $plus_years,
$target_month_enum->value,
Math\minva(
$this->getDay(),
$target_month_enum->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
*
* @psalm-suppress MissingThrowsDocblock - The Math exceptions from Math\div do not result in any error.
*/
public function minusMonths(int $months): static
{
Expand All @@ -438,25 +442,25 @@ 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 = Math\div($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));
$target_month_enum = Month::from($target_month);

return $this->withDate(
$target_year = $this->getYear() - $minus_years,
$target_month_enum->value,
Math\minva(
$this->getDay(),
$target_month_enum->getDaysForYear($target_year)
)
);
}

/**
Expand Down
15 changes: 15 additions & 0 deletions src/Psl/Option/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public function isSome(): bool
* Returns true if the option is a some and the value inside of it matches a predicate.
*
* @param (Closure(T): bool) $predicate
*
* @param-immediately-invoked-callable $predicate
*/
public function isSomeAnd(Closure $predicate): bool
Expand Down Expand Up @@ -141,6 +142,7 @@ public function unwrapOr(mixed $default): mixed
* @template O
*
* @param (Closure(): O) $default
*
* @param-immediately-invoked-callable $default
*
* @return T|O
Expand Down Expand Up @@ -219,6 +221,7 @@ public function orElse(Closure $closure): Option
* - Option<T>::none() if `$predicate` returns false.
*
* @param (Closure(T): bool) $predicate
*
* @param-immediately-invoked-callable $predicate
*
* @return Option<T>
Expand Down Expand Up @@ -256,10 +259,13 @@ public function contains(mixed $value): bool
* @param (Closure(T): Ts) $some A closure to be called when the option is some.
* The closure must accept the option value as its only argument and can return a value.
* Example: `fn($value) => $value + 10`
*
* @param-immediately-invoked-callable $some
*
* @param (Closure(): Ts) $none A closure to be called when the option is none.
* The closure must not accept any arguments and can return a value.
* Example: `fn() => 'Default value'`
*
* @param-immediately-invoked-callable $none
*
* @return Ts The result of calling the appropriate closure.
Expand All @@ -277,6 +283,7 @@ public function proceed(Closure $some, Closure $none): mixed
* Applies a function to a contained value and returns the original `Option<T>`.
*
* @param (Closure(T): mixed) $closure
*
* @param-immediately-invoked-callable $closure
*
* @return Option<T>
Expand All @@ -296,6 +303,7 @@ public function apply(Closure $closure): Option
* @template Tu
*
* @param (Closure(T): Tu) $closure
*
* @param-immediately-invoked-callable $closure
*
* @return Option<Tu>
Expand All @@ -316,6 +324,7 @@ public function map(Closure $closure): Option
* @template Tu
*
* @param (Closure(T): Option<Tu>) $closure
*
* @param-immediately-invoked-callable $closure
*
* @return Option<Tu>
Expand All @@ -340,7 +349,9 @@ public function andThen(Closure $closure): Option
* @template Tu
*
* @param (Closure(T): Tu) $closure
*
* @param-immediately-invoked-callable $closure
*
* @param Tu $default
*
* @return Option<Tu>
Expand All @@ -361,8 +372,11 @@ public function mapOr(Closure $closure, mixed $default): Option
* @template Tu
*
* @param (Closure(T): Tu) $closure
*
* @param-immediately-invoked-callable $closure
*
* @param (Closure(): Tu) $default
*
* @param-immediately-invoked-callable $default
*
* @return Option<Tu>
Expand Down Expand Up @@ -424,6 +438,7 @@ public function zip(Option $other): Option
*
* @param Option<Tu> $other The Option to zip with.
* @param (Closure(T, Tu): Tr) $closure The closure to apply to the values.
*
* @param-immediately-invoked-callable $closure
*
* @return Option<Tr> The new `Option` containing the result of applying the closure to the values,
Expand Down
89 changes: 89 additions & 0 deletions tests/unit/DateTime/DateTimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down

0 comments on commit ae3bf02

Please sign in to comment.