Lines 96.77% 30 / 31
Functions and Methods 90.00% 9 / 10
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
TimeOfDay 96.77% 30 / 31 90.00% 9 / 10 20 0.00% 0 / 1
 __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
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Contracts\Schedule;
6
7use DateTimeImmutable;
8use DateTimeInterface;
9use DateTimeZone;
10use Stewart\Contracts\Exception\ScheduleError;
11use Stewart\Contracts\Exception\ScheduleException;
12use Stewart\Contracts\Time\Instant;
13
14final 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}