Lines 95.00% 19 / 20
Methods 83.33% 5 / 6
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 dailyAt 100.00% 1 / 1 100.00% 1 / 1 1
 findNextOccurrenceAfter 88.88% 8 / 9 0.00% 0 / 1 6.05
 describe 100.00% 7 / 7 100.00% 1 / 1 2
 isRecurring 100.00% 1 / 1 100.00% 1 / 1 1
 fallsOn 100.00% 1 / 1 100.00% 1 / 1 2
15final readonly class CalendarSchedule implements WallClockSchedule
16{
17    // A weekday whose only match falls in a DST gap lands a week later.
18    private const int MAX_SEARCH_DAYS = 15;
19
20    private function __construct(
21        public TimeOfDay $time,
22        public DayOfWeekCollection $days,
23    ) {}
24
25    /** @throws ScheduleException */
26    public static function dailyAt(TimeOfDay|string $time, DayOfWeek ...$days): self
27    {
28        return new self(TimeOfDay::fromTimeOrString($time), DayOfWeekCollection::fromDistinctDays($days));
29    }
30
31    public function findNextOccurrenceAfter(DateTimeImmutable $after): ?DateTimeImmutable
32    {
33        $zone = $after->getTimezone();
34
35        $cursor = new DateTimeImmutable($after->format('Y-m-d'), new DateTimeZone('UTC'));
36
37        for ($offset = 0; $offset <= self::MAX_SEARCH_DAYS; ++$offset) {
38            $day = $offset === 0 ? $cursor : $cursor->modify(\sprintf('+%d days', $offset));
39
40            if (!$this->fallsOn($day)) {
41                continue;
42            }
43
44            $candidate = $this->time->resolveOnDay($day, $zone);
45
46            if ($candidate !== null && $candidate > $after) {
47                return $candidate;
48            }
49        }
50
51        return null;
52    }
53
54    public function describe(): string
55    {
56        if ($this->days->isEmpty()) {
57            return \sprintf('daily at %s', $this->time->format());
58        }
59
60        return \sprintf(
61            'at %s on %s',
62            $this->time->format(),
63            implode(', ', $this->days->listNames()),
64        );
65    }
66
67    public function isRecurring(): bool
68    {
69        return true;
70    }
71
72    private function fallsOn(DateTimeImmutable $day): bool
73    {
74        return $this->days->isEmpty() || $this->days->containsDay(DayOfWeek::fromDateTime($day));
75    }
76}