gothick /
omm
| 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
Bug
introduced
by
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 |