Lines
96.77%
30 / 31
Methods
90.00%
9 / 10
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| fromHourMinuteSecond | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | ||
| parse | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| fromDateTime | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| fromTimeOrString | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 2 | ||
| format | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| toSecondsOfDay | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| resolveOnDay | 92.85% | 13 / 14 | 0.00% | 0 / 1 | 5.01 | ||
| equals | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 3 | ||
| requireInRange | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 3 | ||
| 14 | final readonly class TimeOfDay | |
| 15 | { | |
| 16 | private const string PATTERN = '/\A(\d{1,2}):(\d{2})(?::(\d{2}))?\z/'; | |
| 17 | ||
| 18 | // A day either side of a DST transition reaches the offsets before and after it. | |
| 19 | private const int TRANSITION_PROBE_SECONDS = 86_400; | |
| 20 | ||
| 21 | private function __construct( | |
| 22 | public int $hour, | |
| 23 | public int $minute, | |
| 24 | public int $second, | |
| 25 | ) {} | |
| 26 | ||
| 27 | /** @throws ScheduleException */ | |
| 28 | public static function fromHourMinuteSecond(int $hour, int $minute, int $second = 0): self | |
| 29 | { | |
| 30 | self::requireInRange('hour', $hour, 23); | |
| 31 | self::requireInRange('minute', $minute, 59); | |
| 32 | self::requireInRange('second', $second, 59); | |
| 33 | ||
| 34 | return new self($hour, $minute, $second); | |
| 35 | } | |
| 36 | ||
| 37 | /** @throws ScheduleException */ | |
| 38 | public static function parse(string $time): self | |
| 39 | { | |
| 40 | if (preg_match(self::PATTERN, $time, $matches) !== 1) { | |
| 41 | throw ScheduleException::createForReason(ScheduleError::TimeOfDayInvalid, value: $time); | |
| 42 | } | |
| 43 | ||
| 44 | return self::fromHourMinuteSecond((int) $matches[1], (int) $matches[2], (int) ($matches[3] ?? 0)); | |
| 45 | } | |
| 46 | ||
| 47 | public static function fromDateTime(DateTimeInterface $moment): self | |
| 48 | { | |
| 49 | return new self((int) $moment->format('G'), (int) $moment->format('i'), (int) $moment->format('s')); | |
| 50 | } | |
| 51 | ||
| 52 | /** @throws ScheduleException */ | |
| 53 | public static function fromTimeOrString(self|string $time): self | |
| 54 | { | |
| 55 | return $time instanceof self ? $time : self::parse($time); | |
| 56 | } | |
| 57 | ||
| 58 | public function format(): string | |
| 59 | { | |
| 60 | return \sprintf('%02d:%02d:%02d', $this->hour, $this->minute, $this->second); | |
| 61 | } | |
| 62 | ||
| 63 | public function toSecondsOfDay(): int | |
| 64 | { | |
| 65 | return $this->hour * 3_600 + $this->minute * 60 + $this->second; | |
| 66 | } | |
| 67 | ||
| 68 | public function resolveOnDay(DateTimeInterface $day, DateTimeZone $zone): ?DateTimeImmutable | |
| 69 | { | |
| 70 | $wall = $day->format('Y-m-d') . ' ' . $this->format(); | |
| 71 | $asUtc = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $wall, new DateTimeZone('UTC')); | |
| 72 | ||
| 73 | if ($asUtc === false) { | |
| 74 | return null; | |
| 75 | } | |
| 76 | ||
| 77 | $wallSeconds = $asUtc->getTimestamp(); | |
| 78 | ||
| 79 | /** @var array<int, DateTimeImmutable> $candidates */ | |
| 80 | $candidates = []; | |
| 81 | ||
| 82 | foreach ([-self::TRANSITION_PROBE_SECONDS, self::TRANSITION_PROBE_SECONDS] as $probe) { | |
| 83 | $offset = $zone->getOffset(Instant::fromEpochMicroseconds(($wallSeconds + $probe) * 1_000_000)->toDateTime($zone)); | |
| 84 | $moment = Instant::fromEpochMicroseconds(($wallSeconds - $offset) * 1_000_000)->toDateTime($zone); | |
| 85 | ||
| 86 | if ($moment->format('Y-m-d H:i:s') === $wall) { | |
| 87 | $candidates[$moment->getTimestamp()] = $moment; | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | if ($candidates === []) { | |
| 92 | return null; | |
| 93 | } | |
| 94 | ||
| 95 | return $candidates[min(array_keys($candidates))]; | |
| 96 | } | |
| 97 | ||
| 98 | public function equals(self $other): bool | |
| 99 | { | |
| 100 | return $this->hour === $other->hour | |
| 101 | && $this->minute === $other->minute | |
| 102 | && $this->second === $other->second; | |
| 103 | } | |
| 104 | ||
| 105 | /** @throws ScheduleException */ | |
| 106 | private static function requireInRange(string $unit, int $value, int $highest): void | |
| 107 | { | |
| 108 | if ($value < 0 || $value > $highest) { | |
| 109 | throw ScheduleException::createForReason(ScheduleError::TimeOfDayOutOfRange, unit: $unit, value: $value, highest: $highest); | |
| 110 | } | |
| 111 | } | |
| 112 | } |