diff --git a/app/Http/Controllers/Qr/AnalyticsController.php b/app/Http/Controllers/Qr/AnalyticsController.php new file mode 100644 index 0000000..54f59ab --- /dev/null +++ b/app/Http/Controllers/Qr/AnalyticsController.php @@ -0,0 +1,27 @@ + $this->analytics->summaryForAccount($account), + 'dailyScans' => $this->analytics->dailyScansForAccount($account, 30), + 'devices' => $this->analytics->breakdownForAccount($account, 'device_type'), + 'browsers' => $this->analytics->breakdownForAccount($account, 'browser'), + 'topCodes' => $this->analytics->topCodesForAccount($account), + 'recentScans' => $this->analytics->recentScansForAccount($account), + ]); + } +} diff --git a/app/Services/Qr/QrAnalyticsService.php b/app/Services/Qr/QrAnalyticsService.php index 2a51a9d..f2387b7 100644 --- a/app/Services/Qr/QrAnalyticsService.php +++ b/app/Services/Qr/QrAnalyticsService.php @@ -4,14 +4,119 @@ namespace App\Services\Qr; use App\Models\QrCode; use App\Models\QrScanEvent; -use App\Models\QrWallet; +use App\Models\User; +use App\Support\Qr\QrTypeCatalog; use Carbon\Carbon; -use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class QrAnalyticsService { + /** + * @return array + */ + public function summaryForAccount(User $account): array + { + $codeIds = $this->codeIdsFor($account); + + if ($codeIds === []) { + return $this->emptySummary(); + } + + $codes = QrCode::query()->whereIn('id', $codeIds)->get(['id', 'scans_total', 'unique_scans_total', 'last_scanned_at']); + $now = now(); + $events = QrScanEvent::query()->whereIn('qr_code_id', $codeIds); + + return [ + 'total_scans' => (int) $codes->sum('scans_total'), + 'unique_scans' => (int) $codes->sum('unique_scans_total'), + 'scans_7d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(7))->count(), + 'scans_30d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(30))->count(), + 'last_scanned_at' => $codes->max('last_scanned_at'), + ]; + } + + /** + * @return Collection + */ + public function dailyScansForAccount(User $account, int $days = 30): Collection + { + $codeIds = $this->codeIdsFor($account); + $start = now()->subDays($days - 1)->startOfDay(); + + if ($codeIds === []) { + return $this->emptyDailySeries($start, $days); + } + + $rows = QrScanEvent::query() + ->selectRaw('DATE(scanned_at) as scan_date, COUNT(*) as total') + ->whereIn('qr_code_id', $codeIds) + ->where('scanned_at', '>=', $start) + ->groupBy('scan_date') + ->orderBy('scan_date') + ->get() + ->keyBy('scan_date'); + + return $this->buildDailySeries($start, $days, $rows); + } + + /** + * @return array + */ + public function breakdownForAccount(User $account, string $column, int $limit = 5): array + { + $codeIds = $this->codeIdsFor($account); + + if ($codeIds === []) { + return []; + } + + return QrScanEvent::query() + ->select($column, DB::raw('COUNT(*) as total')) + ->whereIn('qr_code_id', $codeIds) + ->whereNotNull($column) + ->groupBy($column) + ->orderByDesc('total') + ->limit($limit) + ->get() + ->map(fn ($row) => [ + 'label' => (string) $row->{$column}, + 'total' => (int) $row->total, + ]) + ->all(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function recentScansForAccount(User $account, int $limit = 20) + { + $codeIds = $this->codeIdsFor($account); + + if ($codeIds === []) { + return QrScanEvent::query()->whereRaw('0 = 1')->get(); + } + + return QrScanEvent::query() + ->whereIn('qr_code_id', $codeIds) + ->with('qrCode:id,label,short_code') + ->latest('scanned_at') + ->limit($limit) + ->get(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function topCodesForAccount(User $account, int $limit = 10) + { + return $account->qrCodes() + ->whereIn('type', QrTypeCatalog::plusTypes()) + ->orderByDesc('scans_total') + ->limit($limit) + ->get(['id', 'label', 'short_code', 'type', 'scans_total', 'last_scanned_at']); + } + /** * @return array */ @@ -45,16 +150,7 @@ class QrAnalyticsService ->get() ->keyBy('scan_date'); - $series = collect(); - for ($i = 0; $i < $days; $i++) { - $date = $start->copy()->addDays($i)->toDateString(); - $series->push((object) [ - 'date' => $date, - 'total' => (int) ($rows->get($date)?->total ?? 0), - ]); - } - - return $series; + return $this->buildDailySeries($start, $days, $rows); } /** @@ -88,4 +184,49 @@ class QrAnalyticsService ->limit($limit) ->get(); } + + /** @return list */ + private function codeIdsFor(User $account): array + { + return $account->qrCodes() + ->whereIn('type', QrTypeCatalog::plusTypes()) + ->pluck('id') + ->all(); + } + + /** @return array */ + private function emptySummary(): array + { + return [ + 'total_scans' => 0, + 'unique_scans' => 0, + 'scans_7d' => 0, + 'scans_30d' => 0, + 'last_scanned_at' => null, + ]; + } + + /** @return Collection */ + private function emptyDailySeries(Carbon $start, int $days): Collection + { + return $this->buildDailySeries($start, $days, collect()); + } + + /** + * @param Collection $rows + * @return Collection + */ + private function buildDailySeries(Carbon $start, int $days, Collection $rows): Collection + { + $series = collect(); + for ($i = 0; $i < $days; $i++) { + $date = $start->copy()->addDays($i)->toDateString(); + $series->push((object) [ + 'date' => $date, + 'total' => (int) ($rows->get($date)?->total ?? 0), + ]); + } + + return $series; + } } diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 54ff99d..b1dea73 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -10,6 +10,8 @@ 'icon' => ''], ['name' => 'My Codes', 'route' => route('user.qr-codes.index'), 'active' => request()->routeIs('user.qr-codes.*'), 'icon' => ''], + ['name' => 'Analytics', 'route' => route('qr.analytics.index'), 'active' => request()->routeIs('qr.analytics.*'), + 'icon' => ''], ]; @endphp