phase 6
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AnalyticsSession;
|
||||
use App\Models\DailyAggregate;
|
||||
use App\Models\PageDailyAggregate;
|
||||
use App\Models\PageView;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\Website;
|
||||
@@ -12,73 +14,188 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnalyticsQueryService
|
||||
{
|
||||
private readonly Carbon $todayStart;
|
||||
|
||||
public function __construct(
|
||||
private readonly Website $website,
|
||||
private readonly Carbon $from,
|
||||
private readonly Carbon $to,
|
||||
) {}
|
||||
) {
|
||||
$this->todayStart = Carbon::today();
|
||||
}
|
||||
|
||||
public static function for(Website $website, Carbon $from, Carbon $to): self
|
||||
{
|
||||
return new self($website, $from->startOfDay(), $to->endOfDay());
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the requested date range into two parts:
|
||||
* - "aggregate range": completed days that can be served from aggregate tables
|
||||
* - "live range": today's partial data that must come from raw tables
|
||||
*
|
||||
* Returns [?Carbon $aggFrom, ?Carbon $aggTo, ?Carbon $liveFrom, ?Carbon $liveTo]
|
||||
*/
|
||||
private function splitRange(): array
|
||||
{
|
||||
$aggFrom = null;
|
||||
$aggTo = null;
|
||||
$liveFrom = null;
|
||||
$liveTo = null;
|
||||
|
||||
$yesterdayEnd = $this->todayStart->copy()->subSecond();
|
||||
|
||||
if ($this->from->lt($this->todayStart)) {
|
||||
$aggFrom = $this->from->copy();
|
||||
$aggTo = $this->to->lt($this->todayStart) ? $this->to->copy() : $yesterdayEnd;
|
||||
}
|
||||
|
||||
if ($this->to->gte($this->todayStart)) {
|
||||
$liveFrom = $this->from->gte($this->todayStart) ? $this->from->copy() : $this->todayStart->copy();
|
||||
$liveTo = $this->to->copy();
|
||||
}
|
||||
|
||||
return [$aggFrom, $aggTo, $liveFrom, $liveTo];
|
||||
}
|
||||
|
||||
private function hasAggregateRange(): bool
|
||||
{
|
||||
[$aggFrom] = $this->splitRange();
|
||||
|
||||
return $aggFrom !== null;
|
||||
}
|
||||
|
||||
private function hasLiveRange(): bool
|
||||
{
|
||||
[,, $liveFrom] = $this->splitRange();
|
||||
|
||||
return $liveFrom !== null;
|
||||
}
|
||||
|
||||
// ---- Summary Metrics ----
|
||||
|
||||
public function totalPageViews(): int
|
||||
{
|
||||
return PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$this->from, $this->to])
|
||||
->count();
|
||||
$total = 0;
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
|
||||
if ($aggFrom) {
|
||||
$total += (int) DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->sum('page_views');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$total += PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$liveFrom, $liveTo])
|
||||
->count();
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function uniqueVisitors(): int
|
||||
{
|
||||
return PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$this->from, $this->to])
|
||||
->distinct('visitor_id')
|
||||
->count('visitor_id');
|
||||
$total = 0;
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
|
||||
if ($aggFrom) {
|
||||
$total += (int) DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->sum('visitors');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$total += PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$liveFrom, $liveTo])
|
||||
->distinct('visitor_id')
|
||||
->count('visitor_id');
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function totalSessions(): int
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->count();
|
||||
$total = 0;
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
|
||||
if ($aggFrom) {
|
||||
$total += (int) DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->sum('sessions');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$total += AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$liveFrom, $liveTo])
|
||||
->count();
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function bounceRate(): float
|
||||
{
|
||||
$total = $this->totalSessions();
|
||||
if ($total === 0) {
|
||||
$totalSessions = $this->totalSessions();
|
||||
if ($totalSessions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$bounced = AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->where('is_bounce', true)
|
||||
->count();
|
||||
$bounced = 0;
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
|
||||
return round(($bounced / $total) * 100, 1);
|
||||
if ($aggFrom) {
|
||||
$bounced += (int) DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->sum('bounce_count');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$bounced += AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$liveFrom, $liveTo])
|
||||
->where('is_bounce', true)
|
||||
->count();
|
||||
}
|
||||
|
||||
return round(($bounced / $totalSessions) * 100, 1);
|
||||
}
|
||||
|
||||
public function avgSessionDuration(): float
|
||||
{
|
||||
return round(
|
||||
(float) AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->avg('duration') ?? 0,
|
||||
);
|
||||
$totalSessions = $this->totalSessions();
|
||||
if ($totalSessions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalDuration = 0;
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
|
||||
if ($aggFrom) {
|
||||
$totalDuration += (int) DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->sum('total_duration');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$totalDuration += (int) AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$liveFrom, $liveTo])
|
||||
->sum('duration');
|
||||
}
|
||||
|
||||
return round($totalDuration / $totalSessions);
|
||||
}
|
||||
|
||||
public function avgPagesPerSession(): float
|
||||
{
|
||||
return round(
|
||||
(float) AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->avg('pages_count') ?? 0,
|
||||
1,
|
||||
);
|
||||
$totalSessions = $this->totalSessions();
|
||||
if ($totalSessions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalPageViews = $this->totalPageViews();
|
||||
|
||||
return round($totalPageViews / $totalSessions, 1);
|
||||
}
|
||||
|
||||
public function summary(): array
|
||||
@@ -98,46 +215,91 @@ class AnalyticsQueryService
|
||||
public function visitorsOverTime(): 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';
|
||||
$isHourly = $days <= 2;
|
||||
$groupFormat = $isHourly ? '%Y-%m-%d %H:00' : '%Y-%m-%d';
|
||||
$carbonFormat = $isHourly ? 'Y-m-d H:00' : 'Y-m-d';
|
||||
|
||||
$pageViews = PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$this->from, $this->to])
|
||||
->select(
|
||||
DB::raw("strftime('{$groupFormat}', viewed_at) as period"),
|
||||
DB::raw('COUNT(*) as page_views'),
|
||||
DB::raw('COUNT(DISTINCT visitor_id) as visitors'),
|
||||
)
|
||||
->groupBy('period')
|
||||
->orderBy('period')
|
||||
->get()
|
||||
->keyBy('period');
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
$aggregated = collect();
|
||||
$liveData = collect();
|
||||
|
||||
$sessions = AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->select(
|
||||
DB::raw("strftime('{$groupFormat}', started_at) as period"),
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
)
|
||||
->groupBy('period')
|
||||
->orderBy('period')
|
||||
->get()
|
||||
->keyBy('period');
|
||||
if ($aggFrom && ! $isHourly) {
|
||||
$aggregated = DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->select('date as period', 'page_views', 'visitors', 'sessions')
|
||||
->get()
|
||||
->mapWithKeys(fn ($row) => [
|
||||
Carbon::parse($row->period)->format('Y-m-d') => [
|
||||
'page_views' => $row->page_views,
|
||||
'visitors' => $row->visitors,
|
||||
'sessions' => $row->sessions,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$liveQueryFrom = ($aggFrom && ! $isHourly) ? ($liveFrom ?? $this->todayStart) : $this->from;
|
||||
$liveQueryTo = $liveTo ?? $this->to;
|
||||
$needsLive = $isHourly || $liveFrom;
|
||||
|
||||
if ($needsLive || ! $aggFrom) {
|
||||
$rawFrom = $isHourly ? $this->from : $liveQueryFrom;
|
||||
$rawTo = $isHourly ? $this->to : $liveQueryTo;
|
||||
|
||||
$pvRaw = PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$rawFrom, $rawTo])
|
||||
->select(
|
||||
DB::raw("strftime('{$groupFormat}', viewed_at) as period"),
|
||||
DB::raw('COUNT(*) as page_views'),
|
||||
DB::raw('COUNT(DISTINCT visitor_id) as visitors'),
|
||||
)
|
||||
->groupBy('period')
|
||||
->get()
|
||||
->keyBy('period');
|
||||
|
||||
$sessRaw = AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$rawFrom, $rawTo])
|
||||
->select(
|
||||
DB::raw("strftime('{$groupFormat}', started_at) as period"),
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
)
|
||||
->groupBy('period')
|
||||
->get()
|
||||
->keyBy('period');
|
||||
|
||||
foreach ($pvRaw as $key => $row) {
|
||||
$liveData[$key] = [
|
||||
'page_views' => $row->page_views,
|
||||
'visitors' => $row->visitors,
|
||||
'sessions' => $sessRaw->get($key)?->sessions ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($sessRaw as $key => $row) {
|
||||
if (! isset($liveData[$key])) {
|
||||
$liveData[$key] = [
|
||||
'page_views' => 0,
|
||||
'visitors' => 0,
|
||||
'sessions' => $row->sessions,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$merged = $aggregated->union($liveData);
|
||||
|
||||
$periods = collect();
|
||||
$cursor = $this->from->copy();
|
||||
$increment = $days <= 2 ? 'addHour' : 'addDay';
|
||||
$increment = $isHourly ? 'addHour' : 'addDay';
|
||||
|
||||
while ($cursor->lte($this->to)) {
|
||||
$key = $cursor->format($carbonFormat);
|
||||
$pv = $pageViews->get($key);
|
||||
$sess = $sessions->get($key);
|
||||
$data = $merged->get($key);
|
||||
|
||||
$periods->push([
|
||||
'period' => $key,
|
||||
'page_views' => $pv ? $pv->page_views : 0,
|
||||
'visitors' => $pv ? $pv->visitors : 0,
|
||||
'sessions' => $sess ? $sess->sessions : 0,
|
||||
'page_views' => $data['page_views'] ?? 0,
|
||||
'visitors' => $data['visitors'] ?? 0,
|
||||
'sessions' => $data['sessions'] ?? 0,
|
||||
]);
|
||||
|
||||
$cursor->$increment();
|
||||
@@ -295,18 +457,65 @@ class AnalyticsQueryService
|
||||
|
||||
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();
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
$aggData = collect();
|
||||
$liveData = collect();
|
||||
|
||||
if ($aggFrom) {
|
||||
$aggData = PageDailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->select(
|
||||
'path',
|
||||
DB::raw('SUM(views) as views'),
|
||||
DB::raw('SUM(unique_views) as unique_views'),
|
||||
DB::raw('CASE WHEN SUM(views) > 0 THEN SUM(total_time_on_page) * 1.0 / SUM(views) ELSE 0 END as avg_time'),
|
||||
)
|
||||
->groupBy('path')
|
||||
->get()
|
||||
->keyBy('path');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$liveData = PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$liveFrom, $liveTo])
|
||||
->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')
|
||||
->get()
|
||||
->keyBy('path');
|
||||
}
|
||||
|
||||
if ($aggData->isEmpty()) {
|
||||
return $liveData->values()->sortByDesc('views')->take($limit)->values();
|
||||
}
|
||||
|
||||
if ($liveData->isEmpty()) {
|
||||
return $aggData->values()->sortByDesc('views')->take($limit)->values();
|
||||
}
|
||||
|
||||
$allPaths = $aggData->keys()->merge($liveData->keys())->unique();
|
||||
|
||||
return $allPaths->map(function (string $path) use ($aggData, $liveData) {
|
||||
$agg = $aggData->get($path);
|
||||
$live = $liveData->get($path);
|
||||
|
||||
$totalViews = ($agg->views ?? 0) + ($live->views ?? 0);
|
||||
$totalUnique = ($agg->unique_views ?? 0) + ($live->unique_views ?? 0);
|
||||
$avgTime = $totalViews > 0
|
||||
? (($agg ? $agg->avg_time * $agg->views : 0) + ($live ? $live->avg_time * $live->views : 0)) / $totalViews
|
||||
: 0;
|
||||
|
||||
return (object) [
|
||||
'path' => $path,
|
||||
'views' => $totalViews,
|
||||
'unique_views' => $totalUnique,
|
||||
'avg_time' => $avgTime,
|
||||
];
|
||||
})->sortByDesc('views')->take($limit)->values();
|
||||
}
|
||||
|
||||
// ---- Behavior: Landing Pages ----
|
||||
|
||||
Reference in New Issue
Block a user