Passed
Pull Request — master (#144)
by Matt
04:12
created

StatsService::getOverallTimeStats()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 32
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 13
nc 2
nop 0
dl 0
loc 32
rs 9.8333
c 0
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
                ->getOneOrNullResult();
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->getMonthlyStats(
90
                $overallTimeStats['firstWanderStartTime']->startOfMonth(),
91
                $overallTimeStats['latestWanderStartTime']->startOfMonth()
92
            );
93
94
95
96
97
            // Specialist stuff
98
            $qb = $this->wanderRepository
99
                ->createQueryBuilder('w');
100
            $wanderStats['imageProcessingBacklog'] = $this->wanderRepository
101
                ->addWhereHasImages($qb, false)
102
                ->select('COUNT(w.id)')
103
                ->getQuery()
104
                ->getSingleScalarResult();
105
106
            return $wanderStats;
107
        });
108
109
        return $stats;
110
    }
111
112
    /**
113
     * @return array<string, mixed>
114
     */
115
    private function getGeneralWanderStats(): array
116
    {
117
        // General statistics
118
        $wanderStats = $this->wanderRepository
119
            ->createQueryBuilder('w')
120
            ->select('COUNT(w.id) as totalCount')
121
            ->addSelect('COUNT(w.title) as countWithTitle')
122
            ->addSelect('COUNT(w.description) as countWithDescription')
123
            ->addSelect('COALESCE(SUM(w.distance), 0) as totalDistance')
124
            ->addSelect('COALESCE(SUM(w.cumulativeElevationGain), 0) as totalCumulativeElevationGain')
125
            ->getQuery()
126
            ->getOneOrNullResult();
127
128
        $wanderStats['hasWanders'] = $wanderStats['totalCount'] > 0;
129
        return $wanderStats;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $wanderStats could return the type integer|null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
130
    }
131
    /**
132
     * @return array<string, mixed>
133
     */
134
    private function getOverallTimeStats(): array
135
    {
136
        // Doctrine doesn't support calculating a difference
137
        // in seconds from two datetime values via ORM. Let's
138
        // go raw. Seeing as we're aggregating over all wanders
139
        // and want to process the results into Carbon dates,
140
        // we might as well also get the earliest and latest
141
        // wanders, too. These will also be helpful for our monthly
142
        // chart where we don't want to skip missing months.
143
        $conn = $this->entityManager->getConnection();
144
        $sql = 'SELECT
145
                    COALESCE(SUM(TIME_TO_SEC(TIMEDIFF(end_time, start_time))), 0) AS totalDuration,
146
                    COALESCE(AVG(TIME_TO_SEC(TIMEDIFF(end_time, start_time))), 0) AS averageDuration,
147
                    MIN(start_time) AS firstWanderStartTime,
148
                    MAX(start_time) AS latestWanderStartTime
149
                FROM wander
150
            ';
151
        $stmt = $conn->prepare($sql);
152
        $result = $stmt->executeQuery();
153
        $row = $result->fetchAssociative();
154
155
        if ($row === false) {
156
            throw new \Exception("Got no results when finding duration stats.");
157
        }
158
159
        $overallTimeStats = [
160
            'firstWanderStartTime' => Carbon::parse($row['firstWanderStartTime']),
161
            'latestWanderStartTime' => Carbon::parse($row['latestWanderStartTime']),
162
            'totalDuration' => CarbonInterval::seconds($row['totalDuration'])->cascade(),
163
            'averageDuration'=> CarbonInterval::seconds($row['averageDuration'])->cascade()
164
        ];
165
        return $overallTimeStats;
166
    }
167
168
    /**
169
     * @return array<int, array<string, mixed>>
170
     */
171
    private function getMonthlyStats(Carbon $firstWanderMonth, Carbon $latestWanderMonth): array
172
    {
173
        // Stats per month. It would be most efficient to write some complicated SQL query that
174
        // groups the lot together, including filling in months with missing data using some kind
175
        // of row generator or date dimension table, but frankly this is still fast enough,
176
        // especially as it's cached and invalidated quite sensibly.
177
        $sql = 'SELECT
178
                    COUNT(*) AS number_of_wanders,
179
                    COALESCE(SUM(w.distance), 0) AS total_distance_metres,
180
                    COALESCE(SUM(w.distance), 0) / 1000.0 AS total_distance_km,
181
                    COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE i.wander_id = w.id)), 0) AS number_of_images,
182
                    COALESCE(SUM(TIME_TO_SEC(TIMEDIFF(w.end_time, w.start_time))), 0) AS total_duration_seconds,
183
                    COALESCE(AVG(TIME_TO_SEC(TIMEDIFF(w.end_time, w.start_time))), 0) AS average_duration_seconds
184
                FROM
185
                    wander w
186
                WHERE
187
                    w.start_time >= :start AND
188
                    w.start_time < :end';
189
190
        $stmt = $this->entityManager->getConnection()->prepare($sql);
191
192
        $monthlyStats = [];
193
194
        for ($currMonth = $firstWanderMonth; $currMonth <= $latestWanderMonth; $currMonth->addMonths(1)) {
195
            $nextMonth = $currMonth->copy()->addMonths(1);
196
            $result = $stmt->executeQuery([
197
                'start' => $currMonth,
198
                'end' => $nextMonth
199
            ]);
200
            $row = $result->fetchAssociative();
201
            if ($row === false) {
202
                // It's entirely aggregated, so even if no rows match the WHERE there should always be a row
203
                // returned.
204
                throw new \Exception("Expected to get a row back from the database no matter what with this query.");
205
            }
206
            $duration = CarbonInterval::seconds($row['total_duration_seconds'])->cascade();
207
            $monthlyStats[] = [
208
                'firstOfMonthDate' => $currMonth,
209
                'monthLabel' => $currMonth->isoFormat('MMM YYYY'),
210
                'year' => $currMonth->year,
211
                'month' => $currMonth->month,
212
                'numberOfWanders' => (int) $row['number_of_wanders'],
213
                'totalDistance' => (float) $row['total_distance_metres'],
214
                'numberOfImages' => (int) $row['number_of_images'],
215
                'totalDurationInterval' => $duration,
216
                'totalDurationForHumans' => $duration->forHumans(['short' => true, 'options' => 0]),
217
                'averageDurationInterval' => CarbonInterval::seconds($row['average_duration_seconds'])->cascade(),
218
            ];
219
        }
220
        return $monthlyStats;
221
    }
222
}
223