Passed
Pull Request — master (#144)
by Matt
04:14 queued 13s
created

StatsService::getWanderStats()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 47
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 1
eloc 30
nc 1
nop 0
dl 0
loc 47
rs 9.44
c 3
b 0
f 0
1
<?php
2
3
namespace App\Service;
4
5
use App\Entity\Wander;
6
use App\Repository\ImageRepository;
7
use App\Repository\WanderRepository;
8
use Carbon\Carbon;
9
use Carbon\CarbonInterface;
10
use Carbon\CarbonInterval;
11
use Doctrine\ORM\EntityManager;
12
use Doctrine\ORM\EntityManagerInterface;
13
use Doctrine\ORM\EntityRepository;
14
use Exception;
15
use Symfony\Component\ExpressionLanguage\Node\ArrayNode;
16
use Symfony\Contracts\Cache\CacheInterface;
17
use Symfony\Contracts\Cache\ItemInterface;
18
use Symfony\Contracts\Cache\TagAwareCacheInterface;
19
20
class StatsService
21
{
22
    /** @var ImageRepository */
23
    private $imageRepository;
24
25
    /** @var WanderRepository */
26
    private $wanderRepository;
27
28
    /** @var TagAwareCacheInterface */
29
    private $cache;
30
31
    /** @var EntityManagerInterface */
32
    private $entityManager;
33
34
    public function __construct(
35
        ImageRepository $imageRepository,
36
        WanderRepository $wanderRepository,
37
        TagAwareCacheInterface $cache,
38
        EntityManagerInterface $entityManager)
39
    {
40
        $this->imageRepository = $imageRepository;
41
        $this->wanderRepository = $wanderRepository;
42
        $this->cache = $cache;
43
        $this->entityManager = $entityManager;
44
    }
45
46
    /**
47
     * @return array<mixed>
48
     */
49
    public function getImageStats(): array
50
    {
51
        $stats = $this->cache->get('image_stats', function(ItemInterface $item) {
52
            $item->tag('stats');
53
            $imageStats = $this->imageRepository
54
                ->createQueryBuilder('i')
55
                ->select('COUNT(i.id) as totalCount')
56
                ->addSelect('COUNT(i.latlng) as countWithCoords')
57
                ->addSelect('COUNT(i.title) as countWithTitle')
58
                ->addSelect('COUNT(i.description) as countWithDescription')
59
                ->getQuery()
60
                ->getSingleResult();
61
            return $imageStats;
62
        });
63
        return $stats;
64
    }
65
66
    /**
67
     * @return array<mixed>
68
     */
69
    public function getWanderStats(): array
70
    {
71
        $stats = $this->cache->get('wander_stats', function(ItemInterface $item) {
72
            $item->tag('stats');
73
74
            $wanderStats = $this->getGeneralWanderStats();
75
            $overallTimeStats = $this->getOverallTimeStats();
76
77
            $wanderStats += $overallTimeStats;
78
79
            $wanderStats['totalDurationForHumans'] = $wanderStats['totalDuration']
80
                ->forHumans(['short' => true, 'options' => 0]); // https://github.com/briannesbitt/Carbon/issues/2035
81
            $wanderStats['averageDurationForHumans'] = $wanderStats['averageDuration']
82
                ->forHumans(['short' => true, 'options' => 0]); // https://github.com/briannesbitt/Carbon/issues/2035
83
84
            // Distances
85
            $wanderStats['shortestWanderDistance'] = $this->wanderRepository->findShortest();
86
            $wanderStats['longestWanderDistance'] = $this->wanderRepository->findLongest();
87
            $wanderStats['averageWanderDistance'] = $this->wanderRepository->findAverageDistance();
88
89
            $wanderStats['monthlyStats'] = $this->getPeriodicStats(
90
                $overallTimeStats['firstWanderStartTime']->startOfMonth(),
91
                $overallTimeStats['latestWanderStartTime']->startOfMonth(),
92
                1,
93
                'MMM YYYY'
94
            );
95
96
            $wanderStats['yearlyStats'] = $this->getPeriodicStats(
97
                $overallTimeStats['firstWanderStartTime']->startOfYear(),
98
                $overallTimeStats['latestWanderStartTime']->startOfMonth(),
99
                12,
100
                'YYYY'
101
            );
102
103
            // Specialist stuff
104
            $qb = $this->wanderRepository
105
                ->createQueryBuilder('w');
106
            $wanderStats['imageProcessingBacklog'] = $this->wanderRepository
107
                ->addWhereHasImages($qb, false)
108
                ->select('COUNT(w.id)')
109
                ->getQuery()
110
                ->getSingleScalarResult();
111
112
            return $wanderStats;
113
        });
114
115
        return $stats;
116
    }
117
118
    /**
119
     * @return array<string, mixed>
120
     */
121
    private function getGeneralWanderStats(): array
122
    {
123
        // General statistics
124
        $wanderStats = $this->wanderRepository
125
            ->createQueryBuilder('w')
126
            ->select('COUNT(w.id) as totalCount')
127
            ->addSelect('COUNT(w.title) as countWithTitle')
128
            ->addSelect('COUNT(w.description) as countWithDescription')
129
            ->addSelect('COALESCE(SUM(w.distance), 0) as totalDistance')
130
            ->addSelect('COALESCE(SUM(w.cumulativeElevationGain), 0) as totalCumulativeElevationGain')
131
            ->getQuery()
132
            ->getSingleResult();
133
134
        $wanderStats['hasWanders'] = $wanderStats['totalCount'] > 0;
135
        return $wanderStats;
1 ignored issue
show
Bug Best Practice introduced by
The expression return $wanderStats could return the type integer which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
136
    }
137
    /**
138
     * @return array<string, mixed>
139
     */
140
    private function getOverallTimeStats(): array
141
    {
142
        // Doctrine doesn't support calculating a difference
143
        // in seconds from two datetime values via ORM. Let's
144
        // go raw. Seeing as we're aggregating over all wanders
145
        // and want to process the results into Carbon dates,
146
        // we might as well also get the earliest and latest
147
        // wanders, too. These will also be helpful for our monthly
148
        // chart where we don't want to skip missing months.
149
        $conn = $this->entityManager->getConnection();
150
        $sql = 'SELECT
151
                    COALESCE(SUM(TIME_TO_SEC(TIMEDIFF(end_time, start_time))), 0) AS totalDuration,
152
                    COALESCE(AVG(TIME_TO_SEC(TIMEDIFF(end_time, start_time))), 0) AS averageDuration,
153
                    MIN(start_time) AS firstWanderStartTime,
154
                    MAX(start_time) AS latestWanderStartTime
155
                FROM wander
156
            ';
157
        $stmt = $conn->prepare($sql);
158
        $result = $stmt->executeQuery();
159
        $row = $result->fetchAssociative();
160
161
        if ($row === false) {
162
            throw new \Exception("Got no results when finding duration stats.");
163
        }
164
165
        $overallTimeStats = [
166
            'firstWanderStartTime' => Carbon::parse($row['firstWanderStartTime']),
167
            'latestWanderStartTime' => Carbon::parse($row['latestWanderStartTime']),
168
            'totalDuration' => CarbonInterval::seconds($row['totalDuration'])->cascade(),
169
            'averageDuration'=> CarbonInterval::seconds($row['averageDuration'])->cascade()
170
        ];
171
        return $overallTimeStats;
172
    }
173
174
    /**
175
     * @return array<int, array<string, mixed>>
176
     */
177
    private function getPeriodicStats(Carbon $startMonth, Carbon $endMonth, int $periodLengthMonths, string $periodLabelFormat): array
178
    {
179
        // Stats per month or year. It would be most efficient to write some complicated SQL query
180
        // that groups the lot together, including filling in months with missing data using some kind
181
        // of row generator or date dimension table, but frankly this is still fast enough,
182
        // especially as it's cached and invalidated quite sensibly.
183
        $sql = 'SELECT
184
                    COUNT(*) AS number_of_wanders,
185
                    COALESCE(SUM(w.distance), 0) AS total_distance_metres,
186
                    COALESCE(SUM(w.distance), 0) / 1000.0 AS total_distance_km,
187
                    COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE i.wander_id = w.id)), 0) AS number_of_images,
188
                    COALESCE(SUM(TIME_TO_SEC(TIMEDIFF(w.end_time, w.start_time))), 0) AS total_duration_seconds,
189
                    COALESCE(AVG(TIME_TO_SEC(TIMEDIFF(w.end_time, w.start_time))), 0) AS average_duration_seconds
190
                FROM
191
                    wander w
192
                WHERE
193
                    w.start_time >= :start AND
194
                    w.start_time < :end';
195
196
        $stmt = $this->entityManager->getConnection()->prepare($sql);
197
198
        $periodicStats = [];
199
200
        for ($rangeStartMonth = $startMonth->copy(); $rangeStartMonth <= $endMonth; $rangeStartMonth->addMonths($periodLengthMonths)) {
201
            $rangeEndMonth = $rangeStartMonth->copy()->addMonths($periodLengthMonths);
202
            $result = $stmt->executeQuery([
203
                'start' => $rangeStartMonth,
204
                'end' => $rangeEndMonth
205
            ]);
206
            $row = $result->fetchAssociative();
207
            if ($row === false) {
208
                // It's entirely aggregated, so even if no rows match the WHERE there should always be a row
209
                // returned.
210
                throw new \Exception("Expected to get a row back from the database no matter what with this query.");
211
            }
212
            $duration = CarbonInterval::seconds($row['total_duration_seconds'])->cascade();
213
            $periodicStats[] = [
214
                'periodStartDate' => $rangeStartMonth,
215
                'periodEndDate' => $rangeEndMonth,
216
                'periodLabel' => $rangeStartMonth->isoFormat($periodLabelFormat),
217
                'starYear' => $rangeStartMonth->year,
218
                'startMonth' => $rangeStartMonth->month,
219
                'numberOfWanders' => (int) $row['number_of_wanders'],
220
                'totalDistance' => (float) $row['total_distance_metres'],
221
                'numberOfImages' => (int) $row['number_of_images'],
222
                'totalDurationInterval' => $duration,
223
                'totalDurationForHumans' => $duration->forHumans(['short' => true, 'options' => 0]),
224
                'averageDurationInterval' => CarbonInterval::seconds($row['average_duration_seconds'])->cascade(),
225
            ];
226
        }
227
        return $periodicStats;
228
    }
229
}
230