Skip to content

Commit 5071861

Browse files
authored
Merge pull request #22 from assoconnect/month_traveler
Time traveler
2 parents 26a4ea3 + 4a72fc9 commit 5071861

3 files changed

Lines changed: 173 additions & 2 deletions

File tree

src/AbsoluteDate.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,12 @@ public function format(string $format = self::DEFAULT_DATE_FORMAT): string
5656

5757
/**
5858
* Modify the internal datetime
59-
* Supported modify strings :
59+
*
60+
* This method only supports year, month, and day modifiers
6061
* @link https://www.php.net/manual/fr/datetime.formats.php
62+
*
63+
* @see TimeTraveler for coherent modifications month-over-month & year-over-year
64+
*
6165
* @return AbsoluteDate
6266
*/
6367
public function modify(string $modifier): self
@@ -76,7 +80,7 @@ public function modify(string $modifier): self
7680
'ago',
7781
'this',
7882
'of',
79-
'previous'
83+
'previous',
8084
];
8185
preg_match_all('/([a-z]+)/', $modifier, $matches);
8286
$invalidPatterns = array_diff($matches[0], $validPatterns);

src/TimeTraveler.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AssoConnect\PHPDate;
6+
7+
/**
8+
* This class addresses corner-case of the modify('+1 month') method
9+
*/
10+
class TimeTraveler
11+
{
12+
/**
13+
* Adds at most one month to a given date
14+
*
15+
* (new AbsoluteDate(2020-01-31))->modify('+1 month') = 2020-02-31 => 2020-03-02 so more than a month
16+
* addMonth(new AbsoluteDate(2020-01-31)) = 2020-02-29
17+
*/
18+
public function addMonth(AbsoluteDate $from): AbsoluteDate
19+
{
20+
$expectedMonth = (intval($from->format('n')) % 12) + 1;
21+
$next = $from->modify('+1 month');
22+
while ($expectedMonth !== intval($next->format('n'))) {
23+
$next = $next->modify('-1 day');
24+
}
25+
return $next;
26+
}
27+
28+
/**
29+
* Ensures that months calculation are coherent year over year
30+
*
31+
* (new AbsoluteDate(2020-01-31))->modify('+1 year') = 2021-01-31
32+
* (new AbsoluteDate(2020-01-31))
33+
* ->modify('+1 month') = 2020-02-31 => 2020-03-02
34+
* ->modify('+1 month') = 2020-04-02
35+
* ...
36+
* ->modify('+1 month') = 2021-02-02 but we would expect 2021-01-31
37+
*
38+
* $this->addMonth(
39+
* $this->addMonth(
40+
* ...
41+
* $this->addMonth(new AbsoluteDate(2020-01-31))
42+
* ...
43+
* )
44+
* ) = 2021-01-28 but we would expect 2021-01-31
45+
*/
46+
public function addMonthWithReference(AbsoluteDate $reference, AbsoluteDate $from): AbsoluteDate
47+
{
48+
$next = $this->addMonth($from);
49+
$day = min(
50+
intval($reference->format('j')),
51+
intval($next->modify('last day of this month')->format('j'))
52+
);
53+
$dayString = str_pad(strval($day), 2, '0', STR_PAD_LEFT);
54+
return new AbsoluteDate($next->format('Y-m') . '-' . $dayString);
55+
}
56+
57+
/**
58+
* Add at most a year to a date
59+
*
60+
* (new AbsoluteDate(2020-02-29))->modify('+1 year') = 2021-02-29 => 2020-03-01 so more than a year
61+
*/
62+
public function addYear(AbsoluteDate $from): AbsoluteDate
63+
{
64+
$expectedMonth = intval($from->format('n'));
65+
$next = $from->modify('+1 year');
66+
while ($expectedMonth !== intval($next->format('n'))) {
67+
$next = $next->modify('-1 day');
68+
}
69+
return $next;
70+
}
71+
}

tests/TimeTravelerTest.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AssoConnect\PHPDate\Tests;
6+
7+
use AssoConnect\PHPDate\AbsoluteDate;
8+
use AssoConnect\PHPDate\TimeTraveler;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class TimeTravelerTest extends TestCase
12+
{
13+
private TimeTraveler $timeTraveler;
14+
15+
public function setUp(): void
16+
{
17+
$this->timeTraveler = new TimeTraveler();
18+
}
19+
20+
/** @dataProvider provideMonths */
21+
public function testAddMonth(string $from, string $expected): void
22+
{
23+
self::assertSame($expected, $this->timeTraveler->addMonth(new AbsoluteDate($from))->__toString());
24+
}
25+
26+
/** @return array{string, string}[] */
27+
public function provideMonths(): iterable
28+
{
29+
yield ['2020-01-01', '2020-02-01'];
30+
yield ['2020-01-28', '2020-02-28'];
31+
yield ['2020-01-29', '2020-02-29'];
32+
yield ['2020-01-30', '2020-02-29'];
33+
yield ['2020-01-31', '2020-02-29'];
34+
yield ['2020-02-29', '2020-03-29'];
35+
}
36+
37+
/** @dataProvider provideMonthsWithReference */
38+
public function testAddMonthWithReference(string $reference, string $from, string $expected): void
39+
{
40+
self::assertSame($expected, $this->timeTraveler->addMonthWithReference(
41+
new AbsoluteDate($reference),
42+
new AbsoluteDate($from)
43+
)->__toString());
44+
}
45+
46+
/** @return array{string, string, string}[] */
47+
public function provideMonthsWithReference(): iterable
48+
{
49+
yield ['2020-01-01', '2020-01-01', '2020-02-01'];
50+
yield ['2020-01-01', '2020-02-01', '2020-03-01'];
51+
52+
yield ['2020-01-25', '2020-02-25', '2020-03-25'];
53+
54+
// Test the result day matches the reference day
55+
yield ['2020-01-30', '2020-02-29', '2020-03-30'];
56+
yield ['2020-01-31', '2020-02-29', '2020-03-31'];
57+
yield ['2020-01-31', '2020-03-31', '2020-04-30'];
58+
}
59+
60+
/** @dataProvider provideMonthsWithReferenceOverYear */
61+
public function testAddMonthWithReferenceWorksYearOverYear(string $reference, string $expected): void
62+
{
63+
$reference = new AbsoluteDate($reference);
64+
65+
for ($i = 0; $i < 12; $i++) {
66+
$actual = $this->timeTraveler->addMonthWithReference($reference, $actual ?? $reference);
67+
}
68+
self::assertTrue(isset($actual));
69+
self::assertSame($expected, $actual->__toString());
70+
}
71+
72+
/** @return array{string, string}[] */
73+
public function provideMonthsWithReferenceOverYear(): iterable
74+
{
75+
yield ['2020-01-01', '2021-01-01'];
76+
yield ['2020-01-15', '2021-01-15'];
77+
yield ['2020-01-28', '2021-01-28'];
78+
yield ['2020-01-29', '2021-01-29'];
79+
yield ['2020-01-30', '2021-01-30'];
80+
yield ['2020-01-31', '2021-01-31'];
81+
}
82+
83+
/** @dataProvider provideYears */
84+
public function testAddYear(string $from, string $expected): void
85+
{
86+
self::assertSame($expected, $this->timeTraveler->addYear(new AbsoluteDate($from))->__toString());
87+
}
88+
89+
/** @return array{string, string}[] */
90+
public function provideYears(): iterable
91+
{
92+
yield ['2020-01-01', '2021-01-01'];
93+
yield ['2020-01-31', '2021-01-31'];
94+
yield ['2020-02-29', '2021-02-28'];
95+
}
96+
}

0 commit comments

Comments
 (0)