Skip to content

Commit 5123e6e

Browse files
authored
Release/1.5.0 (#7)
* feat: Add distance calculation for days of the week and improve TimeOfDay parsing.
1 parent 0fc8c63 commit 5123e6e

File tree

5 files changed

+254
-11
lines changed

5 files changed

+254
-11
lines changed

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,19 @@ DayOfWeek::Saturday->isWeekday(); # false
380380
DayOfWeek::Saturday->isWeekend(); # true
381381
```
382382

383+
#### Calculating forward distance
384+
385+
Returns the number of days forward from one day to another, always in the range `[0, 6]`. The distance is measured
386+
forward through the week:
387+
388+
```php
389+
use TinyBlocks\Time\DayOfWeek;
390+
391+
DayOfWeek::Monday->distanceTo(other: DayOfWeek::Wednesday); # 2
392+
DayOfWeek::Friday->distanceTo(other: DayOfWeek::Monday); # 3 (forward through Sat, Sun, Mon)
393+
DayOfWeek::Monday->distanceTo(other: DayOfWeek::Monday); # 0
394+
```
395+
383396
### TimeOfDay
384397

385398
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
398411

399412
#### Creating from a string
400413

401-
Parses a string in `HH:MM` format:
414+
Parses a string in `HH:MM` or `HH:MM:SS` format. When seconds are present, they are discarded:
402415

403416
```php
404417
use TinyBlocks\Time\TimeOfDay;
@@ -409,6 +422,18 @@ $time->hour; # 14
409422
$time->minute; # 30
410423
```
411424

425+
Also accepts the `HH:MM:SS` format commonly returned by databases:
426+
427+
```php
428+
use TinyBlocks\Time\TimeOfDay;
429+
430+
$time = TimeOfDay::fromString(value: '08:30:00');
431+
432+
$time->hour; # 8
433+
$time->minute; # 30
434+
$time->toString(); # 08:30
435+
```
436+
412437
#### Deriving from an Instant
413438

414439
Extracts the time of day from an `Instant` in UTC:

src/DayOfWeek.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ enum DayOfWeek: int
1717
case Saturday = 6;
1818
case Sunday = 7;
1919

20+
private const int DAYS_IN_WEEK = 7;
21+
2022
/**
2123
* Derives the day of the week from an Instant.
2224
*
@@ -49,4 +51,21 @@ public function isWeekend(): bool
4951
{
5052
return $this->value >= 6;
5153
}
54+
55+
/**
56+
* Returns the forward distance in days from this day to another day of the week.
57+
* The distance is always in the range [0, 6], measured forward through the week.
58+
*
59+
* For example:
60+
* - Monday->distanceTo(Wednesday) returns 2
61+
* - Friday->distanceTo(Monday) returns 3 (forward through Sat, Sun, Mon)
62+
* - Monday->distanceTo(Monday) returns 0
63+
*
64+
* @param DayOfWeek $other The target day of the week.
65+
* @return int The number of days forward from this day to the other (0–6).
66+
*/
67+
public function distanceTo(DayOfWeek $other): int
68+
{
69+
return ($other->value - $this->value + self::DAYS_IN_WEEK) % self::DAYS_IN_WEEK;
70+
}
5271
}

src/TimeOfDay.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
private const int MAX_MINUTE = 59;
2121
private const int MINUTES_PER_HOUR = 60;
2222

23+
private const string PATTERN = '/^(?P<hour>\d{2}):(?P<minute>\d{2})(?::(?:[0-5]\d))?$/';
24+
2325
private function __construct(public int $hour, public int $minute)
2426
{
2527
}
@@ -46,15 +48,16 @@ public static function from(int $hour, int $minute): TimeOfDay
4648
}
4749

4850
/**
49-
* Creates a TimeOfDay from a string in "HH:MM" format.
51+
* Creates a TimeOfDay from a string in "HH:MM" or "HH:MM:SS" format.
52+
* When seconds are present, they are discarded.
5053
*
51-
* @param string $value The time string (e.g. "08:30", "14:00").
54+
* @param string $value The time string (e.g. "08:30", "14:00", "08:30:00").
5255
* @return TimeOfDay The created time of day.
5356
* @throws InvalidTimeOfDay If the format is invalid or values are out of range.
5457
*/
5558
public static function fromString(string $value): TimeOfDay
5659
{
57-
if (preg_match('/^(?P<hour>\d{2}):(?P<minute>\d{2})$/', $value, $matches) !== 1) {
60+
if (preg_match(self::PATTERN, $value, $matches) !== 1) {
5861
throw InvalidTimeOfDay::becauseFormatIsInvalid(value: $value);
5962
}
6063

tests/DayOfWeekTest.php

Lines changed: 197 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Test\TinyBlocks\Time;
66

7+
use PHPUnit\Framework\Attributes\DataProvider;
78
use PHPUnit\Framework\TestCase;
89
use TinyBlocks\Time\DayOfWeek;
910
use TinyBlocks\Time\Instant;
@@ -116,9 +117,13 @@ public function testDayOfWeekFromInstantOnSunday(): void
116117
public function testDayOfWeekWeekdayAndWeekendAreMutuallyExclusive(): void
117118
{
118119
/** @Then every day should be exactly one of weekday or weekend */
119-
foreach (DayOfWeek::cases() as $day) {
120-
self::assertNotSame($day->isWeekday(), $day->isWeekend());
121-
}
120+
self::assertNotSame(DayOfWeek::Monday->isWeekday(), DayOfWeek::Monday->isWeekend());
121+
self::assertNotSame(DayOfWeek::Tuesday->isWeekday(), DayOfWeek::Tuesday->isWeekend());
122+
self::assertNotSame(DayOfWeek::Wednesday->isWeekday(), DayOfWeek::Wednesday->isWeekend());
123+
self::assertNotSame(DayOfWeek::Thursday->isWeekday(), DayOfWeek::Thursday->isWeekend());
124+
self::assertNotSame(DayOfWeek::Friday->isWeekday(), DayOfWeek::Friday->isWeekend());
125+
self::assertNotSame(DayOfWeek::Saturday->isWeekday(), DayOfWeek::Saturday->isWeekend());
126+
self::assertNotSame(DayOfWeek::Sunday->isWeekday(), DayOfWeek::Sunday->isWeekend());
122127
}
123128

124129
public function testDayOfWeekExactlyFiveWeekdays(): void
@@ -142,4 +147,193 @@ public function testDayOfWeekExactlyTwoWeekendDays(): void
142147

143148
self::assertCount(2, $weekends);
144149
}
150+
151+
#[DataProvider('sameDayDistanceDataProvider')]
152+
public function testDayOfWeekDistanceToSameDayReturnsZero(DayOfWeek $day): void
153+
{
154+
/** @Given the same day of the week */
155+
/** @Then the distance to itself should be zero */
156+
self::assertSame(0, $day->distanceTo(other: $day));
157+
}
158+
159+
#[DataProvider('forwardDistanceDataProvider')]
160+
public function testDayOfWeekDistanceToForward(DayOfWeek $from, DayOfWeek $to, int $expectedDistance): void
161+
{
162+
/** @Given a starting day and a target day */
163+
/** @Then the forward distance should match the expected value */
164+
self::assertSame($expectedDistance, $from->distanceTo(other: $to));
165+
}
166+
167+
#[DataProvider('wrapAroundDistanceDataProvider')]
168+
public function testDayOfWeekDistanceToWrapsAroundWeek(DayOfWeek $from, DayOfWeek $to, int $expectedDistance): void
169+
{
170+
/** @Given a starting day that is after the target day in the week */
171+
/** @Then the distance should wrap forward through the end of the week */
172+
self::assertSame($expectedDistance, $from->distanceTo(other: $to));
173+
}
174+
175+
#[DataProvider('asymmetricDistanceDataProvider')]
176+
public function testDayOfWeekDistanceToIsNotSymmetric(
177+
DayOfWeek $from,
178+
DayOfWeek $to,
179+
int $expectedForward,
180+
int $expectedBackward
181+
): void {
182+
/** @Given two distinct days of the week */
183+
/** @Then the forward and backward distances should differ */
184+
self::assertSame($expectedForward, $from->distanceTo(other: $to));
185+
self::assertSame($expectedBackward, $to->distanceTo(other: $from));
186+
187+
/** @And together they should complete a full week */
188+
self::assertSame(7, $expectedForward + $expectedBackward);
189+
}
190+
191+
#[DataProvider('allPairsDistanceDataProvider')]
192+
public function testDayOfWeekDistanceToNeverExceedsSix(DayOfWeek $from, DayOfWeek $to): void
193+
{
194+
/** @Given any pair of days */
195+
$distance = $from->distanceTo(other: $to);
196+
197+
/** @Then the distance should be in the range [0, 6] */
198+
self::assertGreaterThanOrEqual(0, $distance);
199+
self::assertLessThanOrEqual(6, $distance);
200+
}
201+
202+
public static function sameDayDistanceDataProvider(): array
203+
{
204+
return [
205+
'Monday to Monday' => ['day' => DayOfWeek::Monday],
206+
'Tuesday to Tuesday' => ['day' => DayOfWeek::Tuesday],
207+
'Wednesday to Wednesday' => ['day' => DayOfWeek::Wednesday],
208+
'Thursday to Thursday' => ['day' => DayOfWeek::Thursday],
209+
'Friday to Friday' => ['day' => DayOfWeek::Friday],
210+
'Saturday to Saturday' => ['day' => DayOfWeek::Saturday],
211+
'Sunday to Sunday' => ['day' => DayOfWeek::Sunday]
212+
];
213+
}
214+
215+
public static function forwardDistanceDataProvider(): array
216+
{
217+
return [
218+
'Monday to Tuesday' => [
219+
'from' => DayOfWeek::Monday,
220+
'to' => DayOfWeek::Tuesday,
221+
'expectedDistance' => 1
222+
],
223+
'Monday to Wednesday' => [
224+
'from' => DayOfWeek::Monday,
225+
'to' => DayOfWeek::Wednesday,
226+
'expectedDistance' => 2
227+
],
228+
'Monday to Thursday' => [
229+
'from' => DayOfWeek::Monday,
230+
'to' => DayOfWeek::Thursday,
231+
'expectedDistance' => 3
232+
],
233+
'Monday to Friday' => [
234+
'from' => DayOfWeek::Monday,
235+
'to' => DayOfWeek::Friday,
236+
'expectedDistance' => 4
237+
],
238+
'Monday to Saturday' => [
239+
'from' => DayOfWeek::Monday,
240+
'to' => DayOfWeek::Saturday,
241+
'expectedDistance' => 5
242+
],
243+
'Monday to Sunday' => [
244+
'from' => DayOfWeek::Monday,
245+
'to' => DayOfWeek::Sunday,
246+
'expectedDistance' => 6
247+
],
248+
'Tuesday to Thursday' => [
249+
'from' => DayOfWeek::Tuesday,
250+
'to' => DayOfWeek::Thursday,
251+
'expectedDistance' => 2
252+
],
253+
'Wednesday to Saturday' => [
254+
'from' => DayOfWeek::Wednesday,
255+
'to' => DayOfWeek::Saturday,
256+
'expectedDistance' => 3
257+
]
258+
];
259+
}
260+
261+
public static function wrapAroundDistanceDataProvider(): array
262+
{
263+
return [
264+
'Friday to Monday' => ['from' => DayOfWeek::Friday, 'to' => DayOfWeek::Monday, 'expectedDistance' => 3],
265+
'Saturday to Monday' => [
266+
'from' => DayOfWeek::Saturday,
267+
'to' => DayOfWeek::Monday,
268+
'expectedDistance' => 2
269+
],
270+
'Sunday to Monday' => ['from' => DayOfWeek::Sunday, 'to' => DayOfWeek::Monday, 'expectedDistance' => 1],
271+
'Wednesday to Monday' => [
272+
'from' => DayOfWeek::Wednesday,
273+
'to' => DayOfWeek::Monday,
274+
'expectedDistance' => 5
275+
],
276+
'Saturday to Thursday' => [
277+
'from' => DayOfWeek::Saturday,
278+
'to' => DayOfWeek::Thursday,
279+
'expectedDistance' => 5
280+
],
281+
'Thursday to Tuesday' => [
282+
'from' => DayOfWeek::Thursday,
283+
'to' => DayOfWeek::Tuesday,
284+
'expectedDistance' => 5
285+
],
286+
'Sunday to Wednesday' => [
287+
'from' => DayOfWeek::Sunday,
288+
'to' => DayOfWeek::Wednesday,
289+
'expectedDistance' => 3
290+
]
291+
];
292+
}
293+
294+
public static function asymmetricDistanceDataProvider(): array
295+
{
296+
return [
297+
'Monday and Wednesday' => [
298+
'from' => DayOfWeek::Monday,
299+
'to' => DayOfWeek::Wednesday,
300+
'expectedForward' => 2,
301+
'expectedBackward' => 5
302+
],
303+
'Tuesday and Friday' => [
304+
'from' => DayOfWeek::Tuesday,
305+
'to' => DayOfWeek::Friday,
306+
'expectedForward' => 3,
307+
'expectedBackward' => 4
308+
],
309+
'Thursday and Sunday' => [
310+
'from' => DayOfWeek::Thursday,
311+
'to' => DayOfWeek::Sunday,
312+
'expectedForward' => 3,
313+
'expectedBackward' => 4
314+
],
315+
'Saturday and Monday' => [
316+
'from' => DayOfWeek::Saturday,
317+
'to' => DayOfWeek::Monday,
318+
'expectedForward' => 2,
319+
'expectedBackward' => 5
320+
]
321+
];
322+
}
323+
324+
public static function allPairsDistanceDataProvider(): array
325+
{
326+
$pairs = [];
327+
328+
$days = DayOfWeek::cases();
329+
330+
foreach ($days as $from) {
331+
foreach ($days as $to) {
332+
$label = sprintf('%s to %s', $from->name, $to->name);
333+
$pairs[$label] = ['from' => $from, 'to' => $to];
334+
}
335+
}
336+
337+
return $pairs;
338+
}
145339
}

tests/TimeOfDayTest.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,13 @@ public function testTimeOfDayFromStringWhenEmpty(): void
188188

189189
public function testTimeOfDayFromStringWhenHasSeconds(): void
190190
{
191-
/** @Then an exception indicating that the format is invalid should be thrown */
192-
$this->expectException(InvalidTimeOfDay::class);
191+
/** @Given a time string with seconds */
192+
$time = TimeOfDay::fromString(value: '08:30:00');
193193

194-
/** @When parsing a string with seconds */
195-
TimeOfDay::fromString(value: '08:30:00');
194+
/** @Then the seconds should be discarded and the components should match */
195+
self::assertSame(8, $time->hour);
196+
self::assertSame(30, $time->minute);
197+
self::assertSame('08:30', $time->toString());
196198
}
197199

198200
public function testTimeOfDayFromStringWhenHourOutOfRange(): void

0 commit comments

Comments
 (0)