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;
}
}
@@ -10,6 +10,8 @@
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
['name' => 'My Codes', 'route' => route('user.qr-codes.index'), 'active' => request()->routeIs('user.qr-codes.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 6.75h.75v.75h-.75v-.75ZM6.75 16.5h.75v.75h-.75v-.75ZM16.5 6.75h.75v.75h-.75v-.75ZM13.5 13.5h.75v.75h-.75v-.75ZM16.5 16.5h.75v.75h-.75v-.75ZM13.5 19.5h.75v.75h-.75v-.75ZM19.5 13.5h.75v.75h-.75v-.75ZM19.5 19.5h.75v.75h-.75v-.75ZM22.5 18.75a2.25 2.25 0 0 0-2.25-2.25h-1.5a2.25 2.25 0 0 0-2.25 2.25v1.5a2.25 2.25 0 0 0 2.25 2.25h1.5a2.25 2.25 0 0 0 2.25-2.25v-1.5Z" />'],
['name' => 'Analytics', 'route' => route('qr.analytics.index'), 'active' => request()->routeIs('qr.analytics.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />'],
];
@endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@@ -0,0 +1,115 @@
<x-user-layout>
<x-slot name="title">Analytics</x-slot>
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-slate-900">Analytics</h1>
<p class="mt-1 text-sm text-slate-500">Scan activity across all your QR Plus codes.</p>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['total_scans']) }}</p>
<p class="mt-0.5 text-xs text-slate-400">Total scans</p>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['unique_scans']) }}</p>
<p class="mt-0.5 text-xs text-slate-400">Unique scans</p>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['scans_7d']) }}</p>
<p class="mt-0.5 text-xs text-slate-400">Last 7 days</p>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['scans_30d']) }}</p>
<p class="mt-0.5 text-xs text-slate-400">Last 30 days</p>
</div>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-6 shadow-sm">
<h2 class="text-sm font-semibold text-slate-900">Scans last 30 days</h2>
@php $maxDaily = max(1, $dailyScans->max('total')); @endphp
<div class="mt-5 flex h-28 items-end gap-px">
@foreach($dailyScans as $day)
<div class="group relative flex flex-1 flex-col items-center justify-end"
title="{{ $day->date }}: {{ $day->total }}">
<div class="w-full rounded-t-sm bg-indigo-400/70 transition-colors group-hover:bg-indigo-600"
style="height: {{ max(2, ($day->total / $maxDaily) * 100) }}%"></div>
</div>
@endforeach
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<h2 class="text-sm font-semibold text-slate-900">Devices</h2>
<ul class="mt-4 space-y-2.5">
@forelse($devices as $row)
<li class="flex items-center justify-between text-sm">
<span class="text-slate-600">{{ ucfirst($row['label']) }}</span>
<span class="font-semibold text-slate-900">{{ number_format($row['total']) }}</span>
</li>
@empty
<li class="text-sm text-slate-400">No scans yet</li>
@endforelse
</ul>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<h2 class="text-sm font-semibold text-slate-900">Browsers</h2>
<ul class="mt-4 space-y-2.5">
@forelse($browsers as $row)
<li class="flex items-center justify-between text-sm">
<span class="text-slate-600">{{ $row['label'] }}</span>
<span class="font-semibold text-slate-900">{{ number_format($row['total']) }}</span>
</li>
@empty
<li class="text-sm text-slate-400">No scans yet</li>
@endforelse
</ul>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-6 py-4">
<h2 class="font-semibold text-slate-900">Top codes</h2>
</div>
@if($topCodes->isEmpty())
<p class="px-6 py-8 text-sm text-slate-500">No scan data yet.</p>
@else
<div class="divide-y divide-slate-100">
@foreach($topCodes as $code)
<div class="flex items-center justify-between px-6 py-4">
<a href="{{ route('user.qr-codes.show', $code) }}" class="font-medium text-indigo-600 hover:text-indigo-800">{{ $code->label }}</a>
<span class="text-sm text-slate-600">{{ number_format($code->scans_total) }}</span>
</div>
@endforeach
</div>
@endif
</div>
<div class="rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-6 py-4">
<h2 class="font-semibold text-slate-900">Recent scans</h2>
</div>
@if($recentScans->isEmpty())
<p class="px-6 py-8 text-sm text-slate-500">No scans recorded yet.</p>
@else
<div class="divide-y divide-slate-100">
@foreach($recentScans as $event)
<div class="px-6 py-4">
<p class="text-sm font-medium text-slate-900">{{ $event->qrCode?->label ?? 'QR code' }}</p>
<p class="text-xs text-slate-500">
{{ ucfirst($event->device_type ?? 'unknown') }}
@if($event->browser)
· {{ $event->browser }}
@endif
· {{ $event->scanned_at->diffForHumans() }}
</p>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
</x-user-layout>
+2
View File
@@ -5,6 +5,7 @@ use App\Http\Controllers\NotificationController;
use App\Http\Controllers\Public\QrScanController;
use App\Http\Controllers\Qr\AccountController;
use App\Http\Controllers\Qr\AfiaController;
use App\Http\Controllers\Qr\AnalyticsController;
use App\Http\Controllers\Qr\DeveloperController;
use App\Http\Controllers\Qr\OverviewController;
use App\Http\Controllers\Qr\TeamController;
@@ -38,6 +39,7 @@ Route::middleware(['auth'])->group(function () {
Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read');
Route::get('/dashboard', [OverviewController::class, 'index'])->name('qr.dashboard');
Route::get('/analytics', [AnalyticsController::class, 'index'])->name('qr.analytics.index');
Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('qr.afia.chat');
Route::get('/search', SearchController::class)->name('qr.search');
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Tests\Feature;
use App\Models\QrCode;
use App\Models\QrScanEvent;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class QrAnalyticsTest extends TestCase
{
use RefreshDatabase;
private function user(): User
{
return User::create(['public_id' => (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']);
}
public function test_analytics_page_renders_for_authenticated_user(): void
{
$this->actingAs($this->user())
->get(route('qr.analytics.index'))
->assertOk()
->assertSee('Analytics', false)
->assertSee('Total scans', false)
->assertSee('Top codes', false)
->assertSee('Recent scans', false);
}
public function test_analytics_page_shows_scan_data(): void
{
$user = $this->user();
$code = QrCode::create([
'user_id' => $user->id,
'short_code' => 'test-code-'.uniqid(),
'type' => 'url',
'label' => 'Menu QR',
'destination_url' => 'https://example.com',
'scans_total' => 3,
'unique_scans_total' => 2,
]);
QrScanEvent::create([
'qr_code_id' => $code->id,
'scanned_at' => now()->subDay(),
'device_type' => 'mobile',
'browser' => 'Chrome',
'is_unique' => true,
]);
$this->actingAs($user)
->get(route('qr.analytics.index'))
->assertOk()
->assertSee('Menu QR', false)
->assertSee('3', false)
->assertSee('Chrome', false);
}
}