resolveRange(); foreach (CarbonPeriod::create($from, $to) as $date) { $this->rollupDate(Carbon::parse($date)); $this->line(sprintf('Rolled up Academy analytics for %s.', Carbon::parse($date)->toDateString())); } return self::SUCCESS; } private function rollupDate(Carbon $date): void { $start = $date->copy()->startOfDay(); $end = $date->copy()->endOfDay(); $metrics = []; $uniqueVisitors = []; $engagedDurations = []; AcademyEvent::query() ->whereBetween('occurred_at', [$start, $end]) ->orderBy('id') ->chunkById(1000, function ($events) use (&$metrics, &$uniqueVisitors, &$engagedDurations): void { foreach ($events as $event) { if ($event->is_bot || $event->is_admin || $event->is_suspicious) { continue; } $key = $this->metricKey((string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null); $this->ensureMetric($metrics, (string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null, $key); $visitorKey = $event->user_id ? sprintf('user:%d', (int) $event->user_id) : trim((string) ($event->visitor_id ?? '')); if ($visitorKey !== '') { $uniqueVisitors[$key][$visitorKey] = true; } $eventType = (string) $event->event_type; if (in_array($eventType, ['academy_page_view', 'academy_content_view', 'academy_lesson_view', 'academy_course_view', 'academy_prompt_pack_view', 'academy_challenge_view'], true)) { $metrics[$key]['views']++; if ($event->is_logged_in) { $metrics[$key]['user_views']++; } else { $metrics[$key]['guest_views']++; } if ($event->is_subscriber) { $metrics[$key]['subscriber_views']++; } } if ($eventType === 'academy_engaged_view') { $metrics[$key]['engaged_views']++; $engagedDurations[$key][] = max(0, (int) ($event->metadata['engaged_seconds'] ?? 15)); } if ($eventType === 'academy_scroll_50') { $metrics[$key]['scroll_50']++; } if ($eventType === 'academy_scroll_75') { $metrics[$key]['scroll_75']++; } if ($eventType === 'academy_scroll_100') { $metrics[$key]['scroll_100']++; } if ($eventType === 'academy_prompt_copy') { $metrics[$key]['prompt_copies']++; } if ($eventType === 'academy_prompt_negative_copy') { $metrics[$key]['negative_prompt_copies']++; } if (in_array($eventType, ['academy_lesson_started', 'academy_course_started', 'academy_challenge_started'], true)) { $metrics[$key]['starts']++; } if (in_array($eventType, ['academy_lesson_completed', 'academy_course_completed', 'academy_challenge_submitted'], true)) { $metrics[$key]['completions']++; } if ($eventType === 'academy_upgrade_click') { $metrics[$key]['upgrade_clicks']++; } if ($eventType === 'academy_premium_preview_view') { $metrics[$key]['premium_preview_views']++; } if ($eventType === 'academy_search_result_click') { $metrics[$key]['search_clicks']++; $searchKey = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null); $this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $searchKey); $metrics[$searchKey]['search_clicks']++; } } }); foreach (AcademyLike::query()->whereBetween('created_at', [$start, $end])->get() as $like) { $key = $this->metricKey((string) $like->content_type, (int) $like->content_id); $this->ensureMetric($metrics, (string) $like->content_type, (int) $like->content_id, $key); $metrics[$key]['likes']++; } foreach (AcademySave::query()->whereBetween('created_at', [$start, $end])->get() as $save) { $key = $this->metricKey((string) $save->content_type, (int) $save->content_id); $this->ensureMetric($metrics, (string) $save->content_type, (int) $save->content_id, $key); $metrics[$key]['saves']++; } foreach (AcademySearchLog::query()->whereBetween('created_at', [$start, $end])->get() as $searchLog) { $key = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null); $this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $key); $metrics[$key]['search_impressions']++; if ((int) $searchLog->results_count === 0) { $metrics[$key]['bounce_count']++; } $visitorKey = $searchLog->user_id ? sprintf('user:%d', (int) $searchLog->user_id) : trim((string) ($searchLog->visitor_id ?? '')); if ($visitorKey !== '') { $uniqueVisitors[$key][$visitorKey] = true; } } foreach ($metrics as $key => $metric) { $metric['unique_visitors'] = isset($uniqueVisitors[$key]) ? count($uniqueVisitors[$key]) : 0; $metric['avg_engaged_seconds'] = isset($engagedDurations[$key]) && $engagedDurations[$key] !== [] ? (int) round(array_sum($engagedDurations[$key]) / count($engagedDurations[$key])) : null; $metric['bounce_count'] = max((int) ($metric['bounce_count'] ?? 0), max(0, (int) $metric['views'] - (int) $metric['engaged_views'])); $metric['popularity_score'] = $this->popularity->calculatePopularityScore($metric); $metric['conversion_score'] = $this->popularity->calculateConversionScore($metric); AcademyContentMetricDaily::query()->upsert([ array_merge($metric, [ 'date' => $date->copy()->startOfDay(), 'created_at' => now(), 'updated_at' => now(), ]), ], ['date', 'content_type', 'content_id'], [ 'views', 'unique_visitors', 'guest_views', 'user_views', 'subscriber_views', 'engaged_views', 'scroll_50', 'scroll_75', 'scroll_100', 'likes', 'saves', 'prompt_copies', 'negative_prompt_copies', 'starts', 'completions', 'upgrade_clicks', 'premium_preview_views', 'search_impressions', 'search_clicks', 'bounce_count', 'avg_engaged_seconds', 'popularity_score', 'conversion_score', 'updated_at', ]); } } /** * @param array> $metrics */ private function ensureMetric(array &$metrics, string $contentType, ?int $contentId, string $key): void { if (isset($metrics[$key])) { return; } $metrics[$key] = [ 'content_type' => $contentType, 'content_id' => $contentId, 'views' => 0, 'unique_visitors' => 0, 'guest_views' => 0, 'user_views' => 0, 'subscriber_views' => 0, 'engaged_views' => 0, 'scroll_50' => 0, 'scroll_75' => 0, 'scroll_100' => 0, 'likes' => 0, 'saves' => 0, 'prompt_copies' => 0, 'negative_prompt_copies' => 0, 'starts' => 0, 'completions' => 0, 'upgrade_clicks' => 0, 'premium_preview_views' => 0, 'search_impressions' => 0, 'search_clicks' => 0, 'bounce_count' => 0, 'avg_engaged_seconds' => null, 'popularity_score' => 0, 'conversion_score' => 0, ]; } private function metricKey(string $contentType, ?int $contentId): string { return sprintf('%s:%s', $contentType, $contentId ?? 'none'); } /** * @return array{0: Carbon, 1: Carbon} */ private function resolveRange(): array { $date = $this->option('date'); $from = $this->option('from'); $to = $this->option('to'); if (is_string($date) && trim($date) !== '') { $resolved = Carbon::parse($date)->startOfDay(); return [$resolved, $resolved->copy()]; } $resolvedFrom = is_string($from) && trim($from) !== '' ? Carbon::parse($from)->startOfDay() : now()->subDay()->startOfDay(); $resolvedTo = is_string($to) && trim($to) !== '' ? Carbon::parse($to)->startOfDay() : $resolvedFrom->copy(); return [$resolvedFrom, $resolvedTo]; } }