Add account-level Analytics page to QR Plus.
Deploy Ladill QR Plus / deploy (push) Successful in 39s

Aggregates scan metrics across all codes with charts, breakdowns, and a sidebar nav item.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-19 00:57:08 +00:00
co-authored by Cursor
parent fd4ac37f0a
commit 03fffa04be
6 changed files with 359 additions and 12 deletions
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Services\Qr\QrAnalyticsService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AnalyticsController extends Controller
{
public function __construct(private QrAnalyticsService $analytics) {}
public function index(Request $request): View
{
$account = ladill_account();
return view('qr.analytics.index', [
'summary' => $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),
]);
}
}
+153 -12
View File
@@ -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<string, mixed>
*/
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<int, object{date: string, total: int}>
*/
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<int, array{label: string, total: int}>
*/
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<int, QrScanEvent>
*/
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<int, QrCode>
*/
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<string, mixed>
*/
@@ -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<int> */
private function codeIdsFor(User $account): array
{
return $account->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->pluck('id')
->all();
}
/** @return array<string, mixed> */
private function emptySummary(): array
{
return [
'total_scans' => 0,
'unique_scans' => 0,
'scans_7d' => 0,
'scans_30d' => 0,
'last_scanned_at' => null,
];
}
/** @return Collection<int, object{date: string, total: int}> */
private function emptyDailySeries(Carbon $start, int $days): Collection
{
return $this->buildDailySeries($start, $days, collect());
}
/**
* @param Collection<string, object{scan_date?: string, total?: int}> $rows
* @return Collection<int, object{date: string, total: int}>
*/
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;
}
}