Issues (18)

src/Service/StatsService.php (1 issue)

Labels
Severity
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\CarbonImmutable;
10
use Carbon\CarbonInterface;
11
use Carbon\CarbonInterval;
12
use Doctrine\ORM\EntityManager;
13
use Doctrine\ORM\EntityManagerInterface;
14
use Doctrine\ORM\EntityRepository;
15
use Exception;
16
use Symfony\Component\ExpressionLanguage\Node\ArrayNode;
17
use Symfony\Contracts\Cache\CacheInterface;
18
use Symfony\Contracts\Cache\ItemInterface;
19
use Symfony\Contracts\Cache\TagAwareCacheInterface;
20
21
class StatsService
22
{
23
    /** @var ImageRepository */
24
    private $imageRepository;
25
26
    /** @var WanderRepository */
27
    private $wanderRepository;
28
29
    /** @var TagAwareCacheInterface */
30
    private $cache;
31
32
    /** @var EntityManagerInterface */
33
    private $entityManager;
34
35
    public function __construct(
36
        ImageRepository $imageRepository,
37
        WanderRepository $wanderRepository,
38
        TagAwareCacheInterface $cache,
39
        EntityManagerInterface $entityManager)
40
    {
41
        $this->imageRepository = $imageRepository;
42
        $this->wanderRepository = $wanderRepository;
43
        $this->cache = $cache;
44
        $this->entityManager = $entityManager;
45
    }
46
47
    /**
48
     * @return array<mixed>
49
     */
50
    public function getImageStats(): array
51
    {
52
        $stats = $this->cache->get('image_stats', function(ItemInterface $item) {
53
            $item->tag('stats');
54
            $imageStats = $this->imageRepository
55
                ->createQueryBuilder('i')
56
                ->select('COUNT(i.id) as totalCount')
57
                ->addSelect('COUNT(i.latlng) as countWithCoords')
58
                ->addSelect('COUNT(i.title) as countWithTitle')
59
                ->addSelect('COUNT(i.description) as countWithDescription')
60
                ->getQuery()
61
                ->getSingleResult();
62
            return $imageStats;
63
        });
64
        return $stats;
65
    }
66
67
    /**
68
     * @return array<mixed>
69
     */
70
    public function getImageLocationStats(): array
71
    {
72
        return $this->cache->get('image_location_stats', function (ItemInterface $item) {
73
            $item->tag('stats');
74
            $stats = $this->imageRepository
75
                ->createQueryBuilder('i')
76
                ->select('i.location')
77
                ->addSelect('COUNT(i) AS locationCount')
78
                ->groupBy('i.location')
79
                ->Where('i.location IS NOT NULL')
80
                ->OrderBy('i.location')
81
                ->getQuery()
82
                ->getResult();
83
            return array_column($stats, 'locationCount', 'location');
0 ignored issues
show
It seems like $stats can also be of type integer; however, parameter $array of array_column() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

83
            return array_column(/** @scrutinizer ignore-type */ $stats, 'locationCount', 'location');
Loading history...
84
        });
85
    }
86
87
    /**
88
     * @return array<mixed>
89
     */
90
    public function getWanderStats(): array
91
    {
92
        $stats = $this->cache->get('wander_stats', function(ItemInterface $item) {
93
            $item->tag('stats');
94
95
            $wanderStats = $this->getGeneralWanderStats();
96
            $overallTimeStats = $this->getOverallTimeStats();
97
98
            $wanderStats += $overallTimeStats;
99
100
            $wanderStats['totalDurationForHumans'] = $wanderStats['totalDuration']
101
                ->forHumans(['short' => true, 'options' => 0]); // https://github.com/briannesbitt/Carbon/issues/2035
102
            $wanderStats['averageDurationForHumans'] = $wanderStats['averageDuration']
103
                ->forHumans(['short' => true, 'options' => 0]); // https://github.com/briannesbitt/Carbon/issues/2035
104
105
            // Distances
106
            $wanderStats['shortestWanderDistance'] = $this->wanderRepository->findShortest();
107
            $wanderStats['longestWanderDistance'] = $this->wanderRepository->findLongest();
108
            $wanderStats['averageWanderDistance'] = $this->wanderRepository->findAverageDistance();
109
110
            $wanderStats['monthlyStats'] = $this->getPeriodicStats(
111
                $overallTimeStats['firstWanderStartTime']->startOfMonth(),
112
                $overallTimeStats['latestWanderStartTime']->startOfMonth(),
113
                'month',
114
                'MMM YYYY'
115
            );
116
117
            $wanderStats['yearlyStats'] = $this->getPeriodicStats(
118
                $overallTimeStats['firstWanderStartTime']->startOfYear(),
119
                $overallTimeStats['latestWanderStartTime']->startOfMonth(),
120
                'year',
121
                'YYYY'
122
            );
123
124
            // Specialist stuff
125
            $qb = $this->wanderRepository
126
                ->createQueryBuilder('w');
127
            $wanderStats['imageProcessingBacklog'] = $this->wanderRepository
128
                ->addWhereHasImages($qb, false)
129
                ->select('COUNT(w.id)')
130
                ->getQuery()
131
                ->getSingleScalarResult();
132
133
            return $wanderStats;
134
        });
135
        return $stats;
136
    }
137
138
    /**
139
     * @return array<string, mixed>
140
     */
141
    private function getGeneralWanderStats(): array
142
    {
143
        // General statistics
144
        $wanderStats = $this->wanderRepository
145
            ->createQueryBuilder('w')
146
            ->select('COUNT(w.id) as totalCount')
147
            ->addSelect('COUNT(w.title) as countWithTitle')
148
            ->addSelect('COUNT(w.description) as countWithDescription')
149
            ->addSelect('COALESCE(SUM(w.distance), 0) as totalDistance')
150
            ->addSelect('COALESCE(SUM(w.cumulativeElevationGain), 0) as totalCumulativeElevationGain')
151
            ->getQuery()
152
            ->getSingleResult();
153
154
        $wanderStats['hasWanders'] = $wanderStats['totalCount'] > 0;
155
        return $wanderStats;
156
    }
157
    /**
158
     * @return array<string, mixed>
159
     */
160
    private function getOverallTimeStats(): array
161
    {
162
        // Doctrine doesn't support calculating a difference
163
        // in seconds from two datetime values via ORM. Let's
164
        // go raw. Seeing as we're aggregating over all wanders
165
        // and want to process the results into Carbon dates,
166
        // we might as well also get the earliest and latest
167
        // wanders, too. These will also be helpful for our monthly
168
        // chart where we don't want to skip missing months.
169
        $conn = $this->entityManager->getConnection();
170
        $sql = 'SELECT
171
                    COALESCE(SUM(TIME_TO_SEC(TIMEDIFF(end_time, start_time))), 0) AS totalDuration,
172
                    COALESCE(AVG(TIME_TO_SEC(TIMEDIFF(end_time, start_time))), 0) AS averageDuration,
173
                    MIN(start_time) AS firstWanderStartTime,
174
                    MAX(start_time) AS latestWanderStartTime
175
                FROM wander
176
            ';
177
        $stmt = $conn->prepare($sql);
178
        $result = $stmt->executeQuery();
179
        $row = $result->fetchAssociative();
180
181
        if ($row === false) {
182
            throw new Exception("Got no results when finding duration stats.");
183
        }
184
185
        $overallTimeStats = [
186
            'firstWanderStartTime' => Carbon::parse($row['firstWanderStartTime']),
187
            'latestWanderStartTime' => Carbon::parse($row['latestWanderStartTime']),
188
            'totalDuration' => CarbonInterval::seconds($row['totalDuration'])->cascade(),
189
            'averageDuration'=> CarbonInterval::seconds($row['averageDuration'])->cascade()
190
        ];
191
        return $overallTimeStats;
192
    }
193
194
    /**
195
     * @return array<int, array<string, mixed>>
196
     */
197
    private function getPeriodicStats(Carbon $startMonth, Carbon $endMonth, string $periodType, string $periodLabelFormat): array
198
    {
199
        // Stats per month or year. It would be most efficient to write some complicated SQL query
200
        // that groups the lot together, including filling in months with missing data using some kind
201
        // of row generator or date dimension table, but frankly this is still fast enough,
202
        // especially as it's cached and invalidated quite sensibly.
203
        $sql = 'SELECT
204
                    COUNT(*) AS number_of_wanders,
205
                    COALESCE(SUM(w.distance), 0) AS total_distance_metres,
206
                    COALESCE(SUM(w.distance), 0) / 1000.0 AS total_distance_km,
207
                    COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE i.wander_id = w.id)), 0) AS number_of_images,
208
                    COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE (rating IS NULL or rating = 0) AND i.wander_id = w.id)), 0) AS rating_0_images,
209
                    COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE rating = 1 AND i.wander_id = w.id)), 0) AS rating_1_images,
210
                    COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE rating = 2 AND i.wander_id = w.id)), 0) AS rating_2_images,
211
                    COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE rating = 3 AND i.wander_id = w.id)), 0) AS rating_3_images,
212
                    COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE rating = 4 AND i.wander_id = w.id)), 0) AS rating_4_images,
213
                    COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE rating = 5 AND i.wander_id = w.id)), 0) AS rating_5_images,
214
                    COALESCE(SUM(TIME_TO_SEC(TIMEDIFF(w.end_time, w.start_time))), 0) AS total_duration_seconds,
215
                    COALESCE(AVG(TIME_TO_SEC(TIMEDIFF(w.end_time, w.start_time))), 0) AS average_duration_seconds
216
                FROM
217
                    wander w
218
                WHERE
219
                    w.start_time >= :start AND
220
                    w.start_time < :end';
221
222
        $stmt = $this->entityManager->getConnection()->prepare($sql);
223
224
        $periodicStats = [];
225
226
        $periodLengthMonths = $periodType === 'year' ? 12 : 1;
227
228
        for ($rangeStartMonth = $startMonth->copy(); $rangeStartMonth <= $endMonth; $rangeStartMonth->addMonths($periodLengthMonths)) {
229
            $rangeEndMonth = $rangeStartMonth->copy()->addMonths($periodLengthMonths);
230
            $result = $stmt->executeQuery([
231
                'start' => $rangeStartMonth,
232
                'end' => $rangeEndMonth
233
            ]);
234
            $row = $result->fetchAssociative();
235
            if ($row === false) {
236
                // It's entirely aggregated, so even if no rows match the WHERE there should always be a row
237
                // returned.
238
                throw new Exception("Expected to get a row back from the database no matter what with this query.");
239
            }
240
            $duration = CarbonInterval::seconds($row['total_duration_seconds'])->cascade();
241
            $periodicStats[] = [
242
                'periodType' => $periodType,
243
                'periodStartDate' => new CarbonImmutable($rangeStartMonth),
244
                'periodEndDate' => new CarbonImmutable($rangeEndMonth->copy()->addDays(-1)),
245
                //'year' => $rangeStartMonth->year,
246
                //'month' => $rangeStartMonth->month,
247
                'periodLabel' => $rangeStartMonth->isoFormat($periodLabelFormat),
248
                'numberOfWanders' => (int) $row['number_of_wanders'],
249
                'totalDistance' => (float) $row['total_distance_metres'],
250
                'numberOfImages' => (int) $row['number_of_images'],
251
                'numberOfImagesByRating' => [
252
                    0 => (int) $row['rating_0_images'],
253
                    1 => (int) $row['rating_1_images'],
254
                    2 => (int) $row['rating_2_images'],
255
                    3 => (int) $row['rating_3_images'],
256
                    4 => (int) $row['rating_4_images'],
257
                    5 => (int) $row['rating_5_images'],
258
                ],
259
                'totalDurationInterval' => $duration,
260
                'totalDurationForHumans' => $duration->forHumans(['short' => true, 'options' => 0]),
261
                'averageDurationInterval' => CarbonInterval::seconds($row['average_duration_seconds'])->cascade(),
262
            ];
263
        }
264
        return $periodicStats;
265
    }
266
}
267