Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/AbsoluteDate.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ public function format(string $format = self::DEFAULT_DATE_FORMAT): string

/**
* Modify the internal datetime
* Supported modify strings :
*
* This method only supports year, month, and day modifiers
* @link https://www.php.net/manual/fr/datetime.formats.php
*
* @see TimeTraveler for coherent modifications month-over-month & year-over-year
*
* @return AbsoluteDate
*/
public function modify(string $modifier): self
Expand All @@ -76,7 +80,7 @@ public function modify(string $modifier): self
'ago',
'this',
'of',
'previous'
'previous',
];
preg_match_all('/([a-z]+)/', $modifier, $matches);
$invalidPatterns = array_diff($matches[0], $validPatterns);
Expand Down
71 changes: 71 additions & 0 deletions src/TimeTraveler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace AssoConnect\PHPDate;

/**
* This class addresses corner-case of the modify('+1 month') method
*/
class TimeTraveler
{
/**
* Adds at most one month to a given date
*
* (new AbsoluteDate(2020-01-31))->modify('+1 month') = 2020-02-31 => 2020-03-02 so more than a month
* addMonth(new AbsoluteDate(2020-01-31)) = 2020-02-29
*/
public function addMonth(AbsoluteDate $from): AbsoluteDate
{
$expectedMonth = (intval($from->format('n')) % 12) + 1;
$next = $from->modify('+1 month');
while ($expectedMonth !== intval($next->format('n'))) {
$next = $next->modify('-1 day');
}
return $next;
}

/**
* Ensures that months calculation are coherent year over year
*
* (new AbsoluteDate(2020-01-31))->modify('+1 year') = 2021-01-31
* (new AbsoluteDate(2020-01-31))
* ->modify('+1 month') = 2020-02-31 => 2020-03-02
* ->modify('+1 month') = 2020-04-02
* ...
* ->modify('+1 month') = 2021-02-02 but we would expect 2021-01-31
*
* $this->addMonth(
* $this->addMonth(
* ...
* $this->addMonth(new AbsoluteDate(2020-01-31))
* ...
* )
* ) = 2021-01-28 but we would expect 2021-01-31
*/
public function addMonthWithReference(AbsoluteDate $reference, AbsoluteDate $from): AbsoluteDate
{
$next = $this->addMonth($from);
$day = min(
intval($reference->format('j')),
intval($next->modify('last day of this month')->format('j'))
);
$dayString = str_pad(strval($day), 2, '0', STR_PAD_LEFT);
return new AbsoluteDate($next->format('Y-m') . '-' . $dayString);
}

/**
* Add at most a year to a date
*
* (new AbsoluteDate(2020-02-29))->modify('+1 year') = 2021-02-29 => 2020-03-01 so more than a year
*/
public function addYear(AbsoluteDate $from): AbsoluteDate
{
$expectedMonth = intval($from->format('n'));
$next = $from->modify('+1 year');
while ($expectedMonth !== intval($next->format('n'))) {
$next = $next->modify('-1 day');
}
return $next;
}
}
96 changes: 96 additions & 0 deletions tests/TimeTravelerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace AssoConnect\PHPDate\Tests;

use AssoConnect\PHPDate\AbsoluteDate;
use AssoConnect\PHPDate\TimeTraveler;
use PHPUnit\Framework\TestCase;

class TimeTravelerTest extends TestCase
{
private TimeTraveler $timeTraveler;

public function setUp(): void
{
$this->timeTraveler = new TimeTraveler();
}

/** @dataProvider provideMonths */
public function testAddMonth(string $from, string $expected): void
{
self::assertSame($expected, $this->timeTraveler->addMonth(new AbsoluteDate($from))->__toString());
}

/** @return array{string, string}[] */
public function provideMonths(): iterable
{
yield ['2020-01-01', '2020-02-01'];
yield ['2020-01-28', '2020-02-28'];
yield ['2020-01-29', '2020-02-29'];
yield ['2020-01-30', '2020-02-29'];
yield ['2020-01-31', '2020-02-29'];
yield ['2020-02-29', '2020-03-29'];
}

/** @dataProvider provideMonthsWithReference */
public function testAddMonthWithReference(string $reference, string $from, string $expected): void
{
self::assertSame($expected, $this->timeTraveler->addMonthWithReference(
new AbsoluteDate($reference),
new AbsoluteDate($from)
)->__toString());
}

/** @return array{string, string, string}[] */
public function provideMonthsWithReference(): iterable
{
yield ['2020-01-01', '2020-01-01', '2020-02-01'];
yield ['2020-01-01', '2020-02-01', '2020-03-01'];

yield ['2020-01-25', '2020-02-25', '2020-03-25'];

// Test the result day matches the reference day
yield ['2020-01-30', '2020-02-29', '2020-03-30'];
yield ['2020-01-31', '2020-02-29', '2020-03-31'];
yield ['2020-01-31', '2020-03-31', '2020-04-30'];
}

/** @dataProvider provideMonthsWithReferenceOverYear */
public function testAddMonthWithReferenceWorksYearOverYear(string $reference, string $expected): void
{
$reference = new AbsoluteDate($reference);

for ($i = 0; $i < 12; $i++) {
$actual = $this->timeTraveler->addMonthWithReference($reference, $actual ?? $reference);
}
self::assertTrue(isset($actual));
self::assertSame($expected, $actual->__toString());
}

/** @return array{string, string}[] */
public function provideMonthsWithReferenceOverYear(): iterable
{
yield ['2020-01-01', '2021-01-01'];
yield ['2020-01-15', '2021-01-15'];
yield ['2020-01-28', '2021-01-28'];
yield ['2020-01-29', '2021-01-29'];
yield ['2020-01-30', '2021-01-30'];
yield ['2020-01-31', '2021-01-31'];
}

/** @dataProvider provideYears */
public function testAddYear(string $from, string $expected): void
{
self::assertSame($expected, $this->timeTraveler->addYear(new AbsoluteDate($from))->__toString());
}

/** @return array{string, string}[] */
public function provideYears(): iterable
{
yield ['2020-01-01', '2021-01-01'];
yield ['2020-01-31', '2021-01-31'];
yield ['2020-02-29', '2021-02-28'];
}
}