Lines 100.00% 40 / 40
Methods 100.00% 5 / 5
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 build 100.00% 11 / 11 100.00% 1 / 1 6
 eventEntries 100.00% 7 / 7 100.00% 1 / 1 2
 inTravel 100.00% 4 / 4 100.00% 1 / 1 3
 statEntry 100.00% 17 / 17 100.00% 1 / 1 6
21final readonly class TimelineBuilder
22{
23    public function __construct(private MedalChecker $medalChecker)
24    {
25    }
26
27    /**
28     * @param list<AgentStat>     $stats   newest first
29     * @param list<TimelineEvent> $events
30     * @param list<DoneBanner>    $banners those with the day they were done
31     *
32     * @return list<TimelineEntry>
33     */
34    public function build(array $stats, array $events, array $banners = []): array
35    {
36        $entries = [];
37        foreach ($stats as $index => $stat) {
38            $entries[] = $this->statEntry($stat, $stats[$index + 1] ?? null);
39        }
40
41        foreach ($events as $event) {
42            \array_push($entries, ...$this->eventEntries($event));
43        }
44
45        foreach ($banners as $banner) {
46            if ($banner->doneAt instanceof \DateTimeImmutable) {
47                // As events: before that day's uploads
48                $entries[] = new TimelineEntry($banner->doneAt->setTime(23, 59, 59), banner: $banner);
49            }
50        }
51
52        // usort is stable: uploads and events keep their order on the same date
53        \usort($entries, static fn (TimelineEntry $a, TimelineEntry $b): int => $b->date <=> $a->date);
54
55        $travels = \array_values(\array_filter($events, static fn (TimelineEvent $event): bool => $event->endDate instanceof \DateTimeImmutable));
56
57        return \array_map(static fn (TimelineEntry $entry): TimelineEntry => self::inTravel($entry, $travels) ? $entry->withInTravel() : $entry, $entries);
58    }
59
60    /**
61     * A single-day event at the end of its day: before that day's uploads, newest first. A travel
62     * as its end, at the end of its last day, and its start, at the start of its first: after
63     * that day's uploads, which are in the travel.
64     *
65     * @return list<TimelineEntry>
66     */
67    private function eventEntries(TimelineEvent $event): array
68    {
69        $date = $event->date ?? $event->createdAt;
70        if (!$event->endDate instanceof \DateTimeImmutable) {
71            return [new TimelineEntry($date->setTime(23, 59, 59), event: $event)];
72        }
73
74        return [
75            new TimelineEntry($event->endDate->setTime(23, 59, 59), event: $event, travelEdge: TimelineEntry::TRAVEL_END),
76            new TimelineEntry($date->setTime(0, 0), event: $event, travelEdge: TimelineEntry::TRAVEL_START),
77        ];
78    }
79
80    /**
81     * Whether an entry other than a travel's end or start is on a day of a travel.
82     *
83     * @param list<TimelineEvent> $travels events with an end date
84     */
85    private static function inTravel(TimelineEntry $entry, array $travels): bool
86    {
87        if (null !== $entry->travelEdge) {
88            return false;
89        }
90
91        // Y-m-d compares as text
92        $day = $entry->date->format('Y-m-d');
93
94        return \array_any($travels, fn ($travel) => $day >= ($travel->date ?? $travel->createdAt)->format('Y-m-d') && $day <= $travel->endDate?->format('Y-m-d'));
95    }
96
97    private function statEntry(AgentStat $stat, ?AgentStat $previous): TimelineEntry
98    {
99        if (!$previous instanceof AgentStat) {
100            return new TimelineEntry($stat->datetime, $stat, first: true);
101        }
102
103        $before = [];
104        foreach ($this->medalChecker->medalLevels($previous) as $level) {
105            $before[$level->medal->column] = $level->level;
106        }
107
108        $medalUps = \array_values(\array_filter(
109            $this->medalChecker->medalLevels($stat),
110            static fn (MedalLevel $level): bool => $level->level > ($before[$level->medal->column] ?? 0),
111        ));
112
113        return new TimelineEntry(
114            $stat->datetime,
115            $stat,
116            apGain: \max(0, $stat->ap - $previous->ap),
117            newLevel: null !== $previous->level && $stat->level !== $previous->level ? $stat->level : null,
118            medalUps: $medalUps,
119            recursions: ($stat->recursions ?? 0) > \max(0, $previous->recursions ?? 0) ? $stat->recursions : null,
120        );
121    }
122}