phase 3
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
trait ParsesDateRange
|
||||
{
|
||||
private function parseDateRange(Request $request): array
|
||||
{
|
||||
$range = $request->get('range', '7d');
|
||||
$to = Carbon::today();
|
||||
|
||||
$from = match ($range) {
|
||||
'today' => Carbon::today(),
|
||||
'7d' => Carbon::today()->subDays(6),
|
||||
'30d' => Carbon::today()->subDays(29),
|
||||
'90d' => Carbon::today()->subDays(89),
|
||||
'custom' => Carbon::parse($request->get('from', Carbon::today()->subDays(6)->toDateString())),
|
||||
default => Carbon::today()->subDays(6),
|
||||
};
|
||||
|
||||
if ($range === 'custom' && $request->has('to')) {
|
||||
$to = Carbon::parse($request->get('to'));
|
||||
}
|
||||
|
||||
if ($from->gt($to)) {
|
||||
[$from, $to] = [$to, $from];
|
||||
}
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ParsesDateRange;
|
||||
use App\Models\Website;
|
||||
use App\Services\AnalyticsQueryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use ParsesDateRange;
|
||||
|
||||
public function show(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
@@ -35,30 +37,4 @@ class DashboardController extends Controller
|
||||
'active' => AnalyticsQueryService::activeVisitors($website),
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseDateRange(Request $request): array
|
||||
{
|
||||
$range = $request->get('range', '7d');
|
||||
|
||||
$to = Carbon::today();
|
||||
|
||||
$from = match ($range) {
|
||||
'today' => Carbon::today(),
|
||||
'7d' => Carbon::today()->subDays(6),
|
||||
'30d' => Carbon::today()->subDays(29),
|
||||
'90d' => Carbon::today()->subDays(89),
|
||||
'custom' => Carbon::parse($request->get('from', Carbon::today()->subDays(6)->toDateString())),
|
||||
default => Carbon::today()->subDays(6),
|
||||
};
|
||||
|
||||
if ($range === 'custom' && $request->has('to')) {
|
||||
$to = Carbon::parse($request->get('to'));
|
||||
}
|
||||
|
||||
if ($from->gt($to)) {
|
||||
[$from, $to] = [$to, $from];
|
||||
}
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ParsesDateRange;
|
||||
use App\Models\Website;
|
||||
use App\Services\AnalyticsQueryService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
use ParsesDateRange;
|
||||
|
||||
// ---- Audience ----
|
||||
|
||||
public function geographic(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.audience.geographic', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'countries' => $service->visitorsByCountry(),
|
||||
'cities' => $service->visitorsByCity(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function technology(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.audience.technology', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'browsers' => $service->visitorsByBrowser(),
|
||||
'operatingSystems' => $service->visitorsByOS(),
|
||||
'devices' => $service->visitorsByDeviceType(),
|
||||
'resolutions' => $service->visitorsByScreenResolution(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function language(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.audience.language', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'languages' => $service->visitorsByLanguage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function newVsReturning(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.audience.new-vs-returning', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'data' => $service->newVsReturning(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Acquisition ----
|
||||
|
||||
public function sources(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.acquisition.sources', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'sources' => $service->trafficSources(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function campaigns(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.acquisition.campaigns', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'campaigns' => $service->campaigns(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function referrers(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.acquisition.referrers', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'referrers' => $service->referrers(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Behavior ----
|
||||
|
||||
public function pages(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.behavior.pages', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'pages' => $service->topPages(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function landingPages(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.behavior.landing-pages', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'pages' => $service->landingPages(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitPages(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.behavior.exit-pages', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'pages' => $service->exitPages(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Events ----
|
||||
|
||||
public function events(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.events.overview', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'events' => $service->eventsOverview(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function eventDetail(Request $request, Website $website, string $eventName): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.events.detail', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'eventName' => $eventName,
|
||||
'details' => $service->eventDetail($eventName),
|
||||
'trending' => $service->eventTrending($eventName),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,292 @@ class AnalyticsQueryService
|
||||
return $periods;
|
||||
}
|
||||
|
||||
// ---- Audience: Geographic ----
|
||||
|
||||
public function visitorsByCountry(int $limit = 50): Collection
|
||||
{
|
||||
return Visitor::where('website_id', $this->website->id)
|
||||
->whereNotNull('country')
|
||||
->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to]))
|
||||
->select('country', DB::raw('COUNT(*) as visitors'))
|
||||
->groupBy('country')
|
||||
->orderByDesc('visitors')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function visitorsByCity(int $limit = 50): Collection
|
||||
{
|
||||
return Visitor::where('website_id', $this->website->id)
|
||||
->whereNotNull('city')
|
||||
->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to]))
|
||||
->select('country', 'region', 'city', DB::raw('COUNT(*) as visitors'))
|
||||
->groupBy('country', 'region', 'city')
|
||||
->orderByDesc('visitors')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Audience: Technology ----
|
||||
|
||||
public function visitorsByBrowser(int $limit = 20): Collection
|
||||
{
|
||||
return $this->visitorGroupedBy('browser', $limit);
|
||||
}
|
||||
|
||||
public function visitorsByOS(int $limit = 20): Collection
|
||||
{
|
||||
return $this->visitorGroupedBy('os', $limit);
|
||||
}
|
||||
|
||||
public function visitorsByDeviceType(int $limit = 10): Collection
|
||||
{
|
||||
return $this->visitorGroupedBy('device_type', $limit);
|
||||
}
|
||||
|
||||
public function visitorsByScreenResolution(int $limit = 20): Collection
|
||||
{
|
||||
return $this->visitorGroupedBy('screen_resolution', $limit);
|
||||
}
|
||||
|
||||
// ---- Audience: Language ----
|
||||
|
||||
public function visitorsByLanguage(int $limit = 20): Collection
|
||||
{
|
||||
return $this->visitorGroupedBy('language', $limit);
|
||||
}
|
||||
|
||||
// ---- Audience: New vs Returning ----
|
||||
|
||||
public function newVsReturning(): array
|
||||
{
|
||||
$activeVisitorIds = PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$this->from, $this->to])
|
||||
->distinct()
|
||||
->pluck('visitor_id');
|
||||
|
||||
$total = $activeVisitorIds->count();
|
||||
if ($total === 0) {
|
||||
return ['new' => 0, 'returning' => 0, 'new_pct' => 0, 'returning_pct' => 0];
|
||||
}
|
||||
|
||||
$returning = AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereIn('visitor_id', $activeVisitorIds)
|
||||
->select('visitor_id')
|
||||
->groupBy('visitor_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get()
|
||||
->count();
|
||||
|
||||
$new = $total - $returning;
|
||||
|
||||
return [
|
||||
'new' => $new,
|
||||
'returning' => $returning,
|
||||
'new_pct' => round(($new / $total) * 100, 1),
|
||||
'returning_pct' => round(($returning / $total) * 100, 1),
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Acquisition: Traffic Sources ----
|
||||
|
||||
public function trafficSources(int $limit = 50): Collection
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->select(
|
||||
DB::raw("COALESCE(NULLIF(referrer_domain, ''), 'Direct') as source"),
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
||||
DB::raw('AVG(duration) as avg_duration'),
|
||||
DB::raw('AVG(pages_count) as avg_pages'),
|
||||
)
|
||||
->groupBy('source')
|
||||
->orderByDesc('sessions')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Acquisition: Campaigns (UTM) ----
|
||||
|
||||
public function campaigns(int $limit = 50): Collection
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->whereNotNull('utm_source')
|
||||
->select(
|
||||
'utm_source', 'utm_medium', 'utm_campaign',
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
||||
DB::raw('AVG(duration) as avg_duration'),
|
||||
)
|
||||
->groupBy('utm_source', 'utm_medium', 'utm_campaign')
|
||||
->orderByDesc('sessions')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Acquisition: Referrers ----
|
||||
|
||||
public function referrers(int $limit = 50): Collection
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->whereNotNull('referrer_domain')
|
||||
->where('referrer_domain', '!=', '')
|
||||
->select(
|
||||
'referrer_domain',
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
||||
DB::raw('AVG(duration) as avg_duration'),
|
||||
)
|
||||
->groupBy('referrer_domain')
|
||||
->orderByDesc('sessions')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Behavior: Pages ----
|
||||
|
||||
public function topPages(int $limit = 50): Collection
|
||||
{
|
||||
return PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$this->from, $this->to])
|
||||
->select(
|
||||
'path',
|
||||
DB::raw('COUNT(*) as views'),
|
||||
DB::raw('COUNT(DISTINCT visitor_id) as unique_views'),
|
||||
DB::raw('AVG(time_on_page) as avg_time'),
|
||||
)
|
||||
->groupBy('path')
|
||||
->orderByDesc('views')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Behavior: Landing Pages ----
|
||||
|
||||
public function landingPages(int $limit = 50): Collection
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->whereNotNull('entry_page')
|
||||
->select(
|
||||
'entry_page as path',
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
||||
DB::raw('AVG(duration) as avg_duration'),
|
||||
)
|
||||
->groupBy('entry_page')
|
||||
->orderByDesc('sessions')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Behavior: Exit Pages ----
|
||||
|
||||
public function exitPages(int $limit = 50): Collection
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->whereNotNull('exit_page')
|
||||
->select(
|
||||
'exit_page as path',
|
||||
DB::raw('COUNT(*) as exits'),
|
||||
)
|
||||
->groupBy('exit_page')
|
||||
->orderByDesc('exits')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Events: Overview ----
|
||||
|
||||
public function eventsOverview(int $limit = 50): Collection
|
||||
{
|
||||
return \App\Models\Event::where('website_id', $this->website->id)
|
||||
->whereBetween('occurred_at', [$this->from, $this->to])
|
||||
->select(
|
||||
'name',
|
||||
'category',
|
||||
DB::raw('COUNT(*) as total'),
|
||||
DB::raw('COUNT(DISTINCT visitor_id) as unique_visitors'),
|
||||
DB::raw('SUM(CAST(value AS REAL)) as total_value'),
|
||||
)
|
||||
->groupBy('name', 'category')
|
||||
->orderByDesc('total')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Events: Detail ----
|
||||
|
||||
public function eventDetail(string $eventName): Collection
|
||||
{
|
||||
return \App\Models\Event::where('website_id', $this->website->id)
|
||||
->whereBetween('occurred_at', [$this->from, $this->to])
|
||||
->where('name', $eventName)
|
||||
->select(
|
||||
'action', 'label',
|
||||
DB::raw('COUNT(*) as total'),
|
||||
DB::raw('SUM(CAST(value AS REAL)) as total_value'),
|
||||
)
|
||||
->groupBy('action', 'label')
|
||||
->orderByDesc('total')
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Events: Trending ----
|
||||
|
||||
public function eventTrending(string $eventName): Collection
|
||||
{
|
||||
$days = $this->from->diffInDays($this->to);
|
||||
$groupFormat = $days <= 2 ? '%Y-%m-%d %H:00' : '%Y-%m-%d';
|
||||
$carbonFormat = $days <= 2 ? 'Y-m-d H:00' : 'Y-m-d';
|
||||
|
||||
$data = \App\Models\Event::where('website_id', $this->website->id)
|
||||
->whereBetween('occurred_at', [$this->from, $this->to])
|
||||
->where('name', $eventName)
|
||||
->select(
|
||||
DB::raw("strftime('{$groupFormat}', occurred_at) as period"),
|
||||
DB::raw('COUNT(*) as total'),
|
||||
)
|
||||
->groupBy('period')
|
||||
->orderBy('period')
|
||||
->get()
|
||||
->keyBy('period');
|
||||
|
||||
$periods = collect();
|
||||
$cursor = $this->from->copy();
|
||||
$increment = $days <= 2 ? 'addHour' : 'addDay';
|
||||
|
||||
while ($cursor->lte($this->to)) {
|
||||
$key = $cursor->format($carbonFormat);
|
||||
$periods->push([
|
||||
'period' => $key,
|
||||
'total' => $data->get($key)?->total ?? 0,
|
||||
]);
|
||||
$cursor->$increment();
|
||||
}
|
||||
|
||||
return $periods;
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private function visitorGroupedBy(string $column, int $limit): Collection
|
||||
{
|
||||
return Visitor::where('website_id', $this->website->id)
|
||||
->whereNotNull($column)
|
||||
->where($column, '!=', '')
|
||||
->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to]))
|
||||
->select($column, DB::raw('COUNT(*) as visitors'))
|
||||
->groupBy($column)
|
||||
->orderByDesc('visitors')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Real-Time ----
|
||||
|
||||
public static function activeVisitors(Website $website, int $minutes = 5): int
|
||||
|
||||
Reference in New Issue
Block a user