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
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions src/DayOfWeek.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
}
9 changes: 6 additions & 3 deletions src/TimeOfDay.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
private const int MAX_MINUTE = 59;
private const int MINUTES_PER_HOUR = 60;

private const string PATTERN = '/^(?P<hour>\d{2}):(?P<minute>\d{2})(?::(?:[0-5]\d))?$/';

private function __construct(public int $hour, public int $minute)
{
}
Expand All @@ -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<hour>\d{2}):(?P<minute>\d{2})$/', $value, $matches) !== 1) {
if (preg_match(self::PATTERN, $value, $matches) !== 1) {
throw InvalidTimeOfDay::becauseFormatIsInvalid(value: $value);
}

Expand Down
200 changes: 197 additions & 3 deletions tests/DayOfWeekTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
}
10 changes: 6 additions & 4 deletions tests/TimeOfDayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down