From afdb3c1ad251da4ccb0c324a0f2ef805104a5d4c Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 7 Mar 2026 14:46:08 -0300 Subject: [PATCH 1/2] feat: Add distance calculation for days of the week and improve TimeOfDay parsing. --- README.md | 27 +++++- src/DayOfWeek.php | 19 ++++ src/TimeOfDay.php | 9 +- tests/DayOfWeekTest.php | 200 +++++++++++++++++++++++++++++++++++++++- tests/TimeOfDayTest.php | 10 +- 5 files changed, 254 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3ef967c..824aca1 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,19 @@ DayOfWeek::Saturday->isWeekday(); # false DayOfWeek::Saturday->isWeekend(); # true ``` +#### Calculating forward distance + +Returns the number of days forward from one day to another, always in the range `[0, 6]`. The distance is measured +forward through the week: + +```php +use TinyBlocks\Time\DayOfWeek; + +DayOfWeek::Monday->distanceTo(other: DayOfWeek::Wednesday); # 2 +DayOfWeek::Friday->distanceTo(other: DayOfWeek::Monday); # 3 (forward through Sat, Sun, Mon) +DayOfWeek::Monday->distanceTo(other: DayOfWeek::Monday); # 0 +``` + ### TimeOfDay A `TimeOfDay` represents a time of day (hour and minute) without date or timezone context. Values range from 00:00 to @@ -398,7 +411,7 @@ $time->minute; # 30 #### Creating from a string -Parses a string in `HH:MM` format: +Parses a string in `HH:MM` or `HH:MM:SS` format. When seconds are present, they are discarded: ```php use TinyBlocks\Time\TimeOfDay; @@ -409,6 +422,18 @@ $time->hour; # 14 $time->minute; # 30 ``` +Also accepts the `HH:MM:SS` format commonly returned by databases: + +```php +use TinyBlocks\Time\TimeOfDay; + +$time = TimeOfDay::fromString(value: '08:30:00'); + +$time->hour; # 8 +$time->minute; # 30 +$time->toString(); # 08:30 +``` + #### Deriving from an Instant Extracts the time of day from an `Instant` in UTC: diff --git a/src/DayOfWeek.php b/src/DayOfWeek.php index 9f687d2..6470ab2 100644 --- a/src/DayOfWeek.php +++ b/src/DayOfWeek.php @@ -17,6 +17,8 @@ enum DayOfWeek: int case Saturday = 6; case Sunday = 7; + private const int DAYS_IN_WEEK = 7; + /** * Derives the day of the week from an Instant. * @@ -49,4 +51,21 @@ public function isWeekend(): bool { return $this->value >= 6; } + + /** + * Returns the forward distance in days from this day to another day of the week. + * The distance is always in the range [0, 6], measured forward through the week. + * + * For example: + * - Monday->distanceTo(Wednesday) returns 2 + * - Friday->distanceTo(Monday) returns 3 (forward through Sat, Sun, Mon) + * - Monday->distanceTo(Monday) returns 0 + * + * @param DayOfWeek $other The target day of the week. + * @return int The number of days forward from this day to the other (0–6). + */ + public function distanceTo(DayOfWeek $other): int + { + return ($other->value - $this->value + self::DAYS_IN_WEEK) % self::DAYS_IN_WEEK; + } } diff --git a/src/TimeOfDay.php b/src/TimeOfDay.php index 12594e2..6a24861 100644 --- a/src/TimeOfDay.php +++ b/src/TimeOfDay.php @@ -20,6 +20,8 @@ private const int MAX_MINUTE = 59; private const int MINUTES_PER_HOUR = 60; + private const string PATTERN = '/^(?P\d{2}):(?P\d{2})(?::(?:\d{2}))?$/'; + private function __construct(public int $hour, public int $minute) { } @@ -46,15 +48,16 @@ public static function from(int $hour, int $minute): TimeOfDay } /** - * Creates a TimeOfDay from a string in "HH:MM" format. + * Creates a TimeOfDay from a string in "HH:MM" or "HH:MM:SS" format. + * When seconds are present, they are discarded. * - * @param string $value The time string (e.g. "08:30", "14:00"). + * @param string $value The time string (e.g. "08:30", "14:00", "08:30:00"). * @return TimeOfDay The created time of day. * @throws InvalidTimeOfDay If the format is invalid or values are out of range. */ public static function fromString(string $value): TimeOfDay { - if (preg_match('/^(?P\d{2}):(?P\d{2})$/', $value, $matches) !== 1) { + if (preg_match(self::PATTERN, $value, $matches) !== 1) { throw InvalidTimeOfDay::becauseFormatIsInvalid(value: $value); } diff --git a/tests/DayOfWeekTest.php b/tests/DayOfWeekTest.php index 2caac2a..e45d1ce 100644 --- a/tests/DayOfWeekTest.php +++ b/tests/DayOfWeekTest.php @@ -4,6 +4,7 @@ namespace Test\TinyBlocks\Time; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use TinyBlocks\Time\DayOfWeek; use TinyBlocks\Time\Instant; @@ -116,9 +117,13 @@ public function testDayOfWeekFromInstantOnSunday(): void public function testDayOfWeekWeekdayAndWeekendAreMutuallyExclusive(): void { /** @Then every day should be exactly one of weekday or weekend */ - foreach (DayOfWeek::cases() as $day) { - self::assertNotSame($day->isWeekday(), $day->isWeekend()); - } + self::assertNotSame(DayOfWeek::Monday->isWeekday(), DayOfWeek::Monday->isWeekend()); + self::assertNotSame(DayOfWeek::Tuesday->isWeekday(), DayOfWeek::Tuesday->isWeekend()); + self::assertNotSame(DayOfWeek::Wednesday->isWeekday(), DayOfWeek::Wednesday->isWeekend()); + self::assertNotSame(DayOfWeek::Thursday->isWeekday(), DayOfWeek::Thursday->isWeekend()); + self::assertNotSame(DayOfWeek::Friday->isWeekday(), DayOfWeek::Friday->isWeekend()); + self::assertNotSame(DayOfWeek::Saturday->isWeekday(), DayOfWeek::Saturday->isWeekend()); + self::assertNotSame(DayOfWeek::Sunday->isWeekday(), DayOfWeek::Sunday->isWeekend()); } public function testDayOfWeekExactlyFiveWeekdays(): void @@ -142,4 +147,193 @@ public function testDayOfWeekExactlyTwoWeekendDays(): void self::assertCount(2, $weekends); } + + #[DataProvider('sameDayDistanceDataProvider')] + public function testDayOfWeekDistanceToSameDayReturnsZero(DayOfWeek $day): void + { + /** @Given the same day of the week */ + /** @Then the distance to itself should be zero */ + self::assertSame(0, $day->distanceTo(other: $day)); + } + + #[DataProvider('forwardDistanceDataProvider')] + public function testDayOfWeekDistanceToForward(DayOfWeek $from, DayOfWeek $to, int $expectedDistance): void + { + /** @Given a starting day and a target day */ + /** @Then the forward distance should match the expected value */ + self::assertSame($expectedDistance, $from->distanceTo(other: $to)); + } + + #[DataProvider('wrapAroundDistanceDataProvider')] + public function testDayOfWeekDistanceToWrapsAroundWeek(DayOfWeek $from, DayOfWeek $to, int $expectedDistance): void + { + /** @Given a starting day that is after the target day in the week */ + /** @Then the distance should wrap forward through the end of the week */ + self::assertSame($expectedDistance, $from->distanceTo(other: $to)); + } + + #[DataProvider('asymmetricDistanceDataProvider')] + public function testDayOfWeekDistanceToIsNotSymmetric( + DayOfWeek $from, + DayOfWeek $to, + int $expectedForward, + int $expectedBackward + ): void { + /** @Given two distinct days of the week */ + /** @Then the forward and backward distances should differ */ + self::assertSame($expectedForward, $from->distanceTo(other: $to)); + self::assertSame($expectedBackward, $to->distanceTo(other: $from)); + + /** @And together they should complete a full week */ + self::assertSame(7, $expectedForward + $expectedBackward); + } + + #[DataProvider('allPairsDistanceDataProvider')] + public function testDayOfWeekDistanceToNeverExceedsSix(DayOfWeek $from, DayOfWeek $to): void + { + /** @Given any pair of days */ + $distance = $from->distanceTo(other: $to); + + /** @Then the distance should be in the range [0, 6] */ + self::assertGreaterThanOrEqual(0, $distance); + self::assertLessThanOrEqual(6, $distance); + } + + public static function sameDayDistanceDataProvider(): array + { + return [ + 'Monday to Monday' => ['day' => DayOfWeek::Monday], + 'Tuesday to Tuesday' => ['day' => DayOfWeek::Tuesday], + 'Wednesday to Wednesday' => ['day' => DayOfWeek::Wednesday], + 'Thursday to Thursday' => ['day' => DayOfWeek::Thursday], + 'Friday to Friday' => ['day' => DayOfWeek::Friday], + 'Saturday to Saturday' => ['day' => DayOfWeek::Saturday], + 'Sunday to Sunday' => ['day' => DayOfWeek::Sunday] + ]; + } + + public static function forwardDistanceDataProvider(): array + { + return [ + 'Monday to Tuesday' => [ + 'from' => DayOfWeek::Monday, + 'to' => DayOfWeek::Tuesday, + 'expectedDistance' => 1 + ], + 'Monday to Wednesday' => [ + 'from' => DayOfWeek::Monday, + 'to' => DayOfWeek::Wednesday, + 'expectedDistance' => 2 + ], + 'Monday to Thursday' => [ + 'from' => DayOfWeek::Monday, + 'to' => DayOfWeek::Thursday, + 'expectedDistance' => 3 + ], + 'Monday to Friday' => [ + 'from' => DayOfWeek::Monday, + 'to' => DayOfWeek::Friday, + 'expectedDistance' => 4 + ], + 'Monday to Saturday' => [ + 'from' => DayOfWeek::Monday, + 'to' => DayOfWeek::Saturday, + 'expectedDistance' => 5 + ], + 'Monday to Sunday' => [ + 'from' => DayOfWeek::Monday, + 'to' => DayOfWeek::Sunday, + 'expectedDistance' => 6 + ], + 'Tuesday to Thursday' => [ + 'from' => DayOfWeek::Tuesday, + 'to' => DayOfWeek::Thursday, + 'expectedDistance' => 2 + ], + 'Wednesday to Saturday' => [ + 'from' => DayOfWeek::Wednesday, + 'to' => DayOfWeek::Saturday, + 'expectedDistance' => 3 + ] + ]; + } + + public static function wrapAroundDistanceDataProvider(): array + { + return [ + 'Friday to Monday' => ['from' => DayOfWeek::Friday, 'to' => DayOfWeek::Monday, 'expectedDistance' => 3], + 'Saturday to Monday' => [ + 'from' => DayOfWeek::Saturday, + 'to' => DayOfWeek::Monday, + 'expectedDistance' => 2 + ], + 'Sunday to Monday' => ['from' => DayOfWeek::Sunday, 'to' => DayOfWeek::Monday, 'expectedDistance' => 1], + 'Wednesday to Monday' => [ + 'from' => DayOfWeek::Wednesday, + 'to' => DayOfWeek::Monday, + 'expectedDistance' => 5 + ], + 'Saturday to Thursday' => [ + 'from' => DayOfWeek::Saturday, + 'to' => DayOfWeek::Thursday, + 'expectedDistance' => 5 + ], + 'Thursday to Tuesday' => [ + 'from' => DayOfWeek::Thursday, + 'to' => DayOfWeek::Tuesday, + 'expectedDistance' => 5 + ], + 'Sunday to Wednesday' => [ + 'from' => DayOfWeek::Sunday, + 'to' => DayOfWeek::Wednesday, + 'expectedDistance' => 3 + ] + ]; + } + + public static function asymmetricDistanceDataProvider(): array + { + return [ + 'Monday and Wednesday' => [ + 'from' => DayOfWeek::Monday, + 'to' => DayOfWeek::Wednesday, + 'expectedForward' => 2, + 'expectedBackward' => 5 + ], + 'Tuesday and Friday' => [ + 'from' => DayOfWeek::Tuesday, + 'to' => DayOfWeek::Friday, + 'expectedForward' => 3, + 'expectedBackward' => 4 + ], + 'Thursday and Sunday' => [ + 'from' => DayOfWeek::Thursday, + 'to' => DayOfWeek::Sunday, + 'expectedForward' => 3, + 'expectedBackward' => 4 + ], + 'Saturday and Monday' => [ + 'from' => DayOfWeek::Saturday, + 'to' => DayOfWeek::Monday, + 'expectedForward' => 2, + 'expectedBackward' => 5 + ] + ]; + } + + public static function allPairsDistanceDataProvider(): array + { + $pairs = []; + + $days = DayOfWeek::cases(); + + foreach ($days as $from) { + foreach ($days as $to) { + $label = sprintf('%s to %s', $from->name, $to->name); + $pairs[$label] = ['from' => $from, 'to' => $to]; + } + } + + return $pairs; + } } diff --git a/tests/TimeOfDayTest.php b/tests/TimeOfDayTest.php index 106483b..1aae7e0 100644 --- a/tests/TimeOfDayTest.php +++ b/tests/TimeOfDayTest.php @@ -188,11 +188,13 @@ public function testTimeOfDayFromStringWhenEmpty(): void public function testTimeOfDayFromStringWhenHasSeconds(): void { - /** @Then an exception indicating that the format is invalid should be thrown */ - $this->expectException(InvalidTimeOfDay::class); + /** @Given a time string with seconds */ + $time = TimeOfDay::fromString(value: '08:30:00'); - /** @When parsing a string with seconds */ - TimeOfDay::fromString(value: '08:30:00'); + /** @Then the seconds should be discarded and the components should match */ + self::assertSame(8, $time->hour); + self::assertSame(30, $time->minute); + self::assertSame('08:30', $time->toString()); } public function testTimeOfDayFromStringWhenHourOutOfRange(): void From 49dc934917b8a5912b93e40d30f5cabf14193dcd Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 7 Mar 2026 15:01:53 -0300 Subject: [PATCH 2/2] feat: Add distance calculation for days of the week and improve TimeOfDay parsing. --- src/TimeOfDay.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TimeOfDay.php b/src/TimeOfDay.php index 6a24861..9971484 100644 --- a/src/TimeOfDay.php +++ b/src/TimeOfDay.php @@ -20,7 +20,7 @@ private const int MAX_MINUTE = 59; private const int MINUTES_PER_HOUR = 60; - private const string PATTERN = '/^(?P\d{2}):(?P\d{2})(?::(?:\d{2}))?$/'; + private const string PATTERN = '/^(?P\d{2}):(?P\d{2})(?::(?:[0-5]\d))?$/'; private function __construct(public int $hour, public int $minute) {