todayStart = Carbon::today(); } public static function for(Website $website, Carbon $from, Carbon $to): self { return new self($website, $from->startOfDay(), $to->endOfDay()); } public function withSegment(?Collection $visitorIds): self { $this->segmentVisitorIds = $visitorIds; return $this; } public function isSegmented(): bool { return $this->segmentVisitorIds !== null; } private function applySegmentFilter($query, string $column = 'visitor_id') { if ($this->segmentVisitorIds !== null) { $query->whereIn($column, $this->segmentVisitorIds); } return $query; } /** * 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 { if ($this->isSegmented()) { return $this->applySegmentFilter( 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 { if ($this->isSegmented()) { return $this->applySegmentFilter( 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 { if ($this->isSegmented()) { return $this->applySegmentFilter( 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 { $totalSessions = $this->totalSessions(); if ($totalSessions === 0) { return 0; } $bounced = 0; [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); 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 { $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 { $totalSessions = $this->totalSessions(); if ($totalSessions === 0) { return 0; } $totalPageViews = $this->totalPageViews(); return round($totalPageViews / $totalSessions, 1); } public function summary(): array { return [ 'page_views' => $this->totalPageViews(), 'visitors' => $this->uniqueVisitors(), 'sessions' => $this->totalSessions(), 'bounce_rate' => $this->bounceRate(), 'avg_duration' => $this->avgSessionDuration(), 'avg_pages' => $this->avgPagesPerSession(), ]; } // ---- Time Series ---- public function visitorsOverTime(): Collection { $days = $this->from->diffInDays($this->to); $isHourly = $days <= 2; $groupFormat = $isHourly ? '%Y-%m-%d %H:00' : '%Y-%m-%d'; $carbonFormat = $isHourly ? 'Y-m-d H:00' : 'Y-m-d'; [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); $aggregated = collect(); $liveData = collect(); 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 = $isHourly ? 'addHour' : 'addDay'; while ($cursor->lte($this->to)) { $key = $cursor->format($carbonFormat); $data = $merged->get($key); $periods->push([ 'period' => $key, 'page_views' => $data['page_views'] ?? 0, 'visitors' => $data['visitors'] ?? 0, 'sessions' => $data['sessions'] ?? 0, ]); $cursor->$increment(); } 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 { [$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 ---- 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(); } // ---- Conversions ---- public function conversionsReport(): Collection { $goals = \App\Models\Goal::where('website_id', $this->website->id)->get(); $totalSessions = $this->totalSessions(); return $goals->map(function ($goal) use ($totalSessions) { $completions = \App\Models\GoalConversion::where('goal_id', $goal->id) ->whereBetween('converted_at', [$this->from, $this->to]) ->count(); $totalValue = \App\Models\GoalConversion::where('goal_id', $goal->id) ->whereBetween('converted_at', [$this->from, $this->to]) ->sum('value'); return (object) [ 'id' => $goal->id, 'name' => $goal->name, 'type' => $goal->type, 'is_active' => $goal->is_active, 'completions' => $completions, 'conversion_rate' => $totalSessions > 0 ? round(($completions / $totalSessions) * 100, 2) : 0, 'total_value' => $totalValue, ]; }); } public function goalCompletionsOverTime(): 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\GoalConversion::where('website_id', $this->website->id) ->whereBetween('converted_at', [$this->from, $this->to]) ->select( DB::raw("strftime('{$groupFormat}', converted_at) as period"), DB::raw('COUNT(*) as completions'), ) ->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, 'completions' => $data->get($key)?->completions ?? 0, ]); $cursor->$increment(); } return $periods; } // ---- Real-Time ---- public static function activeVisitors(Website $website, int $minutes = 5): int { return AnalyticsSession::where('website_id', $website->id) ->where('ended_at', '>=', Carbon::now()->subMinutes($minutes)) ->distinct('visitor_id') ->count('visitor_id'); } }