$filters * @return array */ public function getContentOpportunities(array $filters = []): array { return $this->remember('content-opportunities', $filters, function (Carbon $from, Carbon $to, int $limit): array { $searchGaps = $this->getSearchGaps(['from' => $from, 'to' => $to, 'limit' => $limit]); $promptInsights = $this->getPromptInsights(['from' => $from, 'to' => $to, 'limit' => $limit]); $lessonDropoffs = $this->getLessonDropoffs(['from' => $from, 'to' => $to, 'limit' => $limit]); $courseHealth = $this->getCourseHealth(['from' => $from, 'to' => $to, 'limit' => $limit]); $premiumInterest = $this->getPremiumInterest(['from' => $from, 'to' => $to, 'limit' => $limit]); $recommendations = $this->getEditorialRecommendations(['from' => $from, 'to' => $to, 'limit' => $limit]); $cards = [ [ 'label' => 'Content opportunities', 'value' => count($recommendations['rows']), 'description' => 'Actionable content, conversion, and editorial recommendations generated from Academy analytics.', ], [ 'label' => 'Search gaps', 'value' => (int) $searchGaps['summary']['gap_count'], 'description' => 'Queries with zero results, weak CTR, or no result clicks.', ], [ 'label' => 'Prompt insights', 'value' => (int) $promptInsights['summary']['signal_count'], 'description' => 'Prompts that should be improved, promoted, or expanded.', ], [ 'label' => 'Lesson drop-offs', 'value' => (int) $lessonDropoffs['summary']['signal_count'], 'description' => 'Lessons losing users before they meaningfully start or finish.', ], [ 'label' => 'Course health', 'value' => (int) $courseHealth['summary']['signal_count'], 'description' => 'Courses that need restructuring or are ready for expansion.', ], [ 'label' => 'Premium interest', 'value' => (int) $premiumInterest['summary']['signal_count'], 'description' => 'Content that shows premium teaser strength or weakness.', ], [ 'label' => 'Editorial recommendations', 'value' => (int) $recommendations['summary']['total'], 'description' => 'Prioritized actions for what to create, improve, promote, or premiumize next.', ], ]; return [ 'cards' => $cards, 'highlights' => collect($recommendations['rows']) ->take(6) ->map(fn (array $row): array => [ 'title' => (string) $row['title'], 'priority' => (string) $row['priority'], 'reason' => (string) $row['reason'], 'suggested_action' => (string) $row['suggested_action'], ]) ->values() ->all(), ]; }); } /** * @param array $filters * @return array */ public function getSearchGaps(array $filters = []): array { return $this->remember('search-gaps', $filters, function (Carbon $from, Carbon $to, int $limit): array { $rows = $this->searchQuery($from, $to)->get()->map(function ($row): array { $searches = max(0, (int) $row->searches); $clicks = max(0, (int) ($row->clicks ?? 0)); $resultsCount = round((float) ($row->avg_results_count ?? 0), 1); $ctr = $searches > 0 ? round(($clicks / $searches) * 100, 1) : 0.0; $signal = $this->classifySearchGap( searches: $searches, resultsCount: $resultsCount, clicks: $clicks, ctr: $ctr, loggedInSearches: max(0, (int) ($row->logged_in_searches ?? 0)), subscriberSearches: max(0, (int) ($row->subscriber_searches ?? 0)), ); return [ 'query' => (string) ($row->query ?: $row->normalized_query), 'normalized_query' => (string) $row->normalized_query, 'searches' => $searches, 'results_count' => $resultsCount, 'clicks' => $clicks, 'ctr' => $ctr, 'last_searched_at' => $row->last_searched_at ? Carbon::parse((string) $row->last_searched_at)->toDateTimeString() : null, 'logged_in_searches' => max(0, (int) ($row->logged_in_searches ?? 0)), 'subscriber_searches' => max(0, (int) ($row->subscriber_searches ?? 0)), 'issue' => $signal['issue'], 'priority' => $signal['priority'], 'priority_score' => $signal['priority_score'], 'suggested_action' => $signal['suggested_action'], ]; })->filter(fn (array $row): bool => $row['issue'] !== null)->values(); $zeroResultSearches = $rows ->filter(fn (array $row): bool => $row['issue'] === 'Zero-result demand') ->sortByDesc('searches') ->take($limit) ->values(); $searchesWithResultsNoClicks = $rows ->filter(fn (array $row): bool => $row['issue'] === 'Results with no clicks') ->sortByDesc('searches') ->take($limit) ->values(); $lowCtrSearches = $rows ->filter(fn (array $row): bool => $row['issue'] === 'Low click-through rate') ->sortBy('ctr') ->take($limit) ->values(); $highCtrSearches = $rows ->filter(fn (array $row): bool => $row['issue'] === 'High click-through topic') ->sortByDesc('ctr') ->take($limit) ->values(); $repeatedQueries = $rows ->filter(fn (array $row): bool => $row['logged_in_searches'] >= 2 || $row['subscriber_searches'] >= 2) ->sortByDesc('searches') ->take($limit) ->values(); $dedupedRows = $this->dedupeByKey( collect([$zeroResultSearches, $searchesWithResultsNoClicks, $lowCtrSearches, $highCtrSearches]) ->flatten(1) ->sortByDesc('priority_score') ->sortByDesc('searches') ->values(), 'normalized_query', )->take($limit)->values(); return [ 'summary' => [ 'gap_count' => $dedupedRows->count(), 'zero_result_count' => $zeroResultSearches->count(), 'no_click_count' => $searchesWithResultsNoClicks->count(), 'low_ctr_count' => $lowCtrSearches->count(), 'high_ctr_count' => $highCtrSearches->count(), 'repeated_member_count' => $repeatedQueries->count(), ], 'rows' => $dedupedRows->all(), 'zero_result_searches' => $zeroResultSearches->all(), 'searches_with_results_no_clicks' => $searchesWithResultsNoClicks->all(), 'low_ctr_searches' => $lowCtrSearches->all(), 'high_ctr_searches' => $highCtrSearches->all(), 'repeated_queries' => $repeatedQueries->all(), ]; }); } /** * @param array $filters * @return array */ public function getPromptInsights(array $filters = []): array { return $this->remember('prompt-insights', $filters, function (Carbon $from, Carbon $to, int $limit): array { $rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT)->map(function (array $row): ?array { $signal = $this->classifyPromptInsight($row); if ($signal === null) { return null; } return array_merge($row, $signal); })->filter()->sortByDesc('priority_score')->take($limit)->values(); return [ 'summary' => [ 'signal_count' => $rows->count(), 'high_view_low_copy' => $rows->where('issue', 'High views, low copies')->count(), 'low_view_high_copy_rate' => $rows->where('issue', 'Low views, high copy rate')->count(), 'high_save_low_copy' => $rows->where('issue', 'High saves, low copies')->count(), 'high_copy_low_like' => $rows->where('issue', 'High copies, low likes')->count(), 'high_upgrade_interest' => $rows->where('issue', 'High upgrade interest')->count(), ], 'rows' => $rows->all(), ]; }); } /** * @param array $filters * @return array */ public function getLessonDropoffs(array $filters = []): array { return $this->remember('lesson-dropoffs', $filters, function (Carbon $from, Carbon $to, int $limit): array { $rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::LESSON)->map(function (array $row): ?array { $signal = $this->classifyLessonDropoff($row); if ($signal === null) { return null; } return array_merge($row, $signal); })->filter()->sortByDesc('priority_score')->take($limit)->values(); return [ 'summary' => [ 'signal_count' => $rows->count(), 'low_start_rate' => $rows->where('issue', 'High views, low starts')->count(), 'low_completion_rate' => $rows->where('issue', 'High starts, low completions')->count(), 'underpromoted_winners' => $rows->where('issue', 'High completions, low views')->count(), 'upgrade_interest' => $rows->where('issue', 'Upgrade interest')->count(), ], 'rows' => $rows->all(), ]; }); } /** * @param array $filters * @return array */ public function getCourseHealth(array $filters = []): array { return $this->remember('course-health', $filters, function (Carbon $from, Carbon $to, int $limit): array { $progress = AcademyUserProgress::query() ->selectRaw('course_id, avg(progress_percent) as avg_progress_percent, count(*) as learners') ->whereNotNull('course_id') ->whereNull('lesson_id') ->whereBetween('updated_at', [$from, $to]) ->groupBy('course_id') ->get() ->keyBy(fn ($row): int => (int) $row->course_id); $rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::COURSE)->map(function (array $row) use ($progress): ?array { $courseProgress = $progress->get((int) $row['content_id']); $row['avg_progress'] = $courseProgress ? round((float) ($courseProgress->avg_progress_percent ?? 0), 1) : 0.0; $row['learners'] = $courseProgress ? (int) ($courseProgress->learners ?? 0) : 0; $signal = $this->classifyCourseHealth($row); if ($signal === null) { return null; } return array_merge($row, $signal); })->filter()->sortByDesc('priority_score')->take($limit)->values(); return [ 'summary' => [ 'signal_count' => $rows->count(), 'low_start_rate' => $rows->where('issue', 'Low course start rate')->count(), 'low_completion_rate' => $rows->where('issue', 'Low course completion rate')->count(), 'expandable_courses' => $rows->where('issue', 'Expansion candidate')->count(), 'upgrade_interest' => $rows->where('issue', 'Premium follow-up opportunity')->count(), ], 'rows' => $rows->all(), ]; }); } /** * @param array $filters * @return array */ public function getPremiumInterest(array $filters = []): array { return $this->remember('premium-interest', $filters, function (Carbon $from, Carbon $to, int $limit): array { $rows = collect([ ...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT)->all(), ...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::LESSON)->all(), ...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::COURSE)->all(), ...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT_PACK)->all(), ...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::CHALLENGE)->all(), ])->map(function (array $row): ?array { $signal = $this->classifyPremiumInterest($row); if ($signal === null) { return null; } return array_merge($row, $signal, [ 'premium_interest_score' => round(((float) $row['premium_preview_views'] * 2) + ((float) $row['upgrade_clicks'] * 10), 1), ]); })->filter()->sortByDesc('priority_score')->sortByDesc('premium_interest_score')->take($limit)->values(); return [ 'summary' => [ 'signal_count' => $rows->count(), 'strong_candidates' => $rows->where('issue', 'Strong premium candidate')->count(), 'weak_teasers' => $rows->where('issue', 'Weak premium teaser')->count(), ], 'rows' => $rows->all(), ]; }); } /** * @param array $filters * @return array */ public function getEditorialRecommendations(array $filters = []): array { return $this->remember('editorial-recommendations', $filters, function (Carbon $from, Carbon $to, int $limit): array { $searchGaps = $this->getSearchGaps(['from' => $from, 'to' => $to, 'limit' => $limit]); $promptInsights = $this->getPromptInsights(['from' => $from, 'to' => $to, 'limit' => $limit]); $lessonDropoffs = $this->getLessonDropoffs(['from' => $from, 'to' => $to, 'limit' => $limit]); $courseHealth = $this->getCourseHealth(['from' => $from, 'to' => $to, 'limit' => $limit]); $premiumInterest = $this->getPremiumInterest(['from' => $from, 'to' => $to, 'limit' => $limit]); $recommendations = collect(); foreach (array_slice($searchGaps['zero_result_searches'], 0, 5) as $row) { $recommendations->push([ 'title' => sprintf('Create content for "%s"', $row['query']), 'description' => sprintf('Users searched for "%s" %d times and saw %.1f results.', $row['query'], $row['searches'], $row['results_count']), 'reason' => 'Repeated zero-result searches indicate missing Academy content coverage.', 'priority' => $row['searches'] >= 3 ? 'high' : 'medium', 'priority_score' => $row['searches'] >= 3 ? 300 + $row['searches'] : 200 + $row['searches'], 'content_type' => null, 'content_id' => null, 'metric_snapshot' => [ 'searches' => $row['searches'], 'results_count' => $row['results_count'], 'clicks' => $row['clicks'], ], 'suggested_action' => 'Create content for this topic', ]); } foreach (array_slice($promptInsights['rows'], 0, 4) as $row) { $recommendations->push([ 'title' => sprintf('Review prompt "%s"', $row['title']), 'description' => sprintf('%s with %d views, %d copies, and a %.1f%% copy rate.', $row['issue'], $row['views'], $row['prompt_copies'], $row['copy_rate']), 'reason' => 'Prompt performance suggests either discoverability or quality improvements are needed.', 'priority' => $row['priority'], 'priority_score' => 180 + (int) $row['priority_score'], 'content_type' => $row['content_type'], 'content_id' => $row['content_id'], 'metric_snapshot' => [ 'views' => $row['views'], 'copies' => $row['prompt_copies'], 'copy_rate' => $row['copy_rate'], 'upgrade_clicks' => $row['upgrade_clicks'], ], 'suggested_action' => $row['suggested_action'], ]); } foreach (array_slice($lessonDropoffs['rows'], 0, 4) as $row) { $recommendations->push([ 'title' => sprintf('Improve lesson "%s"', $row['title']), 'description' => sprintf('%s with %d starts and a %.1f%% completion rate.', $row['issue'], $row['starts'], $row['completion_rate']), 'reason' => 'Lesson funnel data shows where learners hesitate or drop off.', 'priority' => $row['priority'], 'priority_score' => 170 + (int) $row['priority_score'], 'content_type' => $row['content_type'], 'content_id' => $row['content_id'], 'metric_snapshot' => [ 'views' => $row['views'], 'starts' => $row['starts'], 'completions' => $row['completions'], 'completion_rate' => $row['completion_rate'], ], 'suggested_action' => $row['suggested_action'], ]); } foreach (array_slice($courseHealth['rows'], 0, 4) as $row) { $recommendations->push([ 'title' => sprintf('Review course "%s"', $row['title']), 'description' => sprintf('%s with a %.1f%% completion rate and %.1f%% average progress.', $row['issue'], $row['completion_rate'], $row['avg_progress']), 'reason' => 'Course progression data highlights where sequencing or positioning may be blocking learners.', 'priority' => $row['priority'], 'priority_score' => 160 + (int) $row['priority_score'], 'content_type' => $row['content_type'], 'content_id' => $row['content_id'], 'metric_snapshot' => [ 'views' => $row['views'], 'starts' => $row['starts'], 'completions' => $row['completions'], 'avg_progress' => $row['avg_progress'], ], 'suggested_action' => $row['suggested_action'], ]); } foreach (array_slice($premiumInterest['rows'], 0, 4) as $row) { $recommendations->push([ 'title' => sprintf('Use "%s" as a premium signal', $row['title']), 'description' => sprintf('%s with %d preview views and %d upgrade clicks.', $row['issue'], $row['premium_preview_views'], $row['upgrade_clicks']), 'reason' => 'Premium preview behavior shows which topics can sell subscriptions or need better teaser copy.', 'priority' => $row['priority'], 'priority_score' => 150 + (int) $row['priority_score'], 'content_type' => $row['content_type'], 'content_id' => $row['content_id'], 'metric_snapshot' => [ 'premium_preview_views' => $row['premium_preview_views'], 'upgrade_clicks' => $row['upgrade_clicks'], 'upgrade_rate' => $row['upgrade_rate'], ], 'suggested_action' => $row['suggested_action'], ]); } $rows = $recommendations ->sortByDesc('priority_score') ->take($limit) ->values() ->map(function (array $row): array { unset($row['priority_score']); return $row; }); return [ 'summary' => [ 'total' => $rows->count(), 'high_priority' => $rows->where('priority', 'high')->count(), 'medium_priority' => $rows->where('priority', 'medium')->count(), 'low_priority' => $rows->where('priority', 'low')->count(), ], 'rows' => $rows->all(), ]; }); } /** * @param array $filters * @return array{0: Carbon, 1: Carbon, 2: int} */ private function resolveFilters(array $filters): array { $from = ($filters['from'] ?? null) instanceof Carbon ? $filters['from']->copy()->startOfDay() : Carbon::parse((string) ($filters['from'] ?? now()->subDays(29)->toDateString()))->startOfDay(); $to = ($filters['to'] ?? null) instanceof Carbon ? $filters['to']->copy()->endOfDay() : Carbon::parse((string) ($filters['to'] ?? now()->toDateString()))->endOfDay(); $limit = max(1, min(50, (int) ($filters['limit'] ?? 25))); return [$from, $to, $limit]; } /** * @param array $filters * @return array */ private function remember(string $suffix, array $filters, callable $callback): array { [$from, $to, $limit] = $this->resolveFilters($filters); return Cache::remember( sprintf('academy_analytics_%s:%s:%s:%d', $suffix, $from->toDateString(), $to->toDateString(), $limit), now()->addMinutes(10), fn (): array => $callback($from, $to, $limit), ); } private function searchQuery(Carbon $from, Carbon $to): Builder { return AcademySearchLog::query() ->whereBetween('created_at', [$from, $to]) ->selectRaw('normalized_query, max(query) as query, count(*) as searches, avg(results_count) as avg_results_count, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, max(created_at) as last_searched_at, sum(case when is_logged_in = 1 then 1 else 0 end) as logged_in_searches, sum(case when is_subscriber = 1 then 1 else 0 end) as subscriber_searches') ->whereNotNull('normalized_query') ->groupBy('normalized_query'); } /** * @return Collection> */ private function contentMetrics(Carbon $from, Carbon $to, string $contentType): Collection { return AcademyContentMetricDaily::query() ->whereBetween('date', [$from->toDateString(), $to->toDateString()]) ->where('content_type', $contentType) ->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(negative_prompt_copies) as negative_prompt_copies, sum(starts) as starts, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(premium_preview_views) as premium_preview_views, sum(search_clicks) as search_clicks, sum(popularity_score) as popularity_score') ->groupBy('content_type', 'content_id') ->get() ->map(function ($row) use ($contentType): array { $contentId = (int) $row->content_id; $uniqueVisitors = max(0, (int) ($row->unique_visitors ?? 0)); $promptCopies = max(0, (int) ($row->prompt_copies ?? 0)); $likes = max(0, (int) ($row->likes ?? 0)); $saves = max(0, (int) ($row->saves ?? 0)); $starts = max(0, (int) ($row->starts ?? 0)); $completions = max(0, (int) ($row->completions ?? 0)); $searchClicks = max(0, (int) ($row->search_clicks ?? 0)); $premiumPreviewViews = max(0, (int) ($row->premium_preview_views ?? 0)); $upgradeClicks = max(0, (int) ($row->upgrade_clicks ?? 0)); return [ 'content_type' => $contentType, 'content_id' => $contentId, 'content_type_label' => (string) Str::of(str_replace('academy_', '', $contentType))->replace('_', ' ')->headline(), 'title' => $this->resolver->title($contentType, $contentId), 'views' => max(0, (int) ($row->views ?? 0)), 'unique_visitors' => $uniqueVisitors, 'engaged_views' => max(0, (int) ($row->engaged_views ?? 0)), 'likes' => $likes, 'saves' => $saves, 'prompt_copies' => $promptCopies, 'negative_prompt_copies' => max(0, (int) ($row->negative_prompt_copies ?? 0)), 'starts' => $starts, 'completions' => $completions, 'search_clicks' => $searchClicks, 'premium_preview_views' => $premiumPreviewViews, 'upgrade_clicks' => $upgradeClicks, 'popularity_score' => round((float) ($row->popularity_score ?? 0), 2), 'copy_rate' => $uniqueVisitors > 0 ? round(($promptCopies / $uniqueVisitors) * 100, 1) : 0.0, 'save_rate' => $uniqueVisitors > 0 ? round(($saves / $uniqueVisitors) * 100, 1) : 0.0, 'like_rate' => $uniqueVisitors > 0 ? round(($likes / $uniqueVisitors) * 100, 1) : 0.0, 'search_click_rate' => $uniqueVisitors > 0 ? round(($searchClicks / $uniqueVisitors) * 100, 1) : 0.0, 'start_rate' => $uniqueVisitors > 0 ? round(($starts / $uniqueVisitors) * 100, 1) : 0.0, 'completion_rate' => $starts > 0 ? round(($completions / $starts) * 100, 1) : 0.0, 'engagement_rate' => $uniqueVisitors > 0 ? round((((int) ($row->engaged_views ?? 0)) / $uniqueVisitors) * 100, 1) : 0.0, 'upgrade_rate' => $premiumPreviewViews > 0 ? round(($upgradeClicks / $premiumPreviewViews) * 100, 1) : 0.0, ]; }); } /** * @return array{issue: string|null, priority: string, priority_score: int, suggested_action: string} */ private function classifySearchGap(int $searches, float $resultsCount, int $clicks, float $ctr, int $loggedInSearches, int $subscriberSearches): array { if ($resultsCount <= 0.4) { return [ 'issue' => 'Zero-result demand', 'priority' => $searches >= 3 || $subscriberSearches >= 2 ? 'high' : 'medium', 'priority_score' => 300 + $searches, 'suggested_action' => 'Create content for this topic', ]; } if ($resultsCount > 0 && $clicks === 0) { return [ 'issue' => 'Results with no clicks', 'priority' => $searches >= 3 || $loggedInSearches >= 2 ? 'high' : 'medium', 'priority_score' => 240 + $searches, 'suggested_action' => 'Improve titles, excerpts, thumbnails, or relevance', ]; } if ($searches >= 2 && $ctr < 10) { return [ 'issue' => 'Low click-through rate', 'priority' => 'medium', 'priority_score' => 180 + $searches, 'suggested_action' => 'Improve matching content or create better content', ]; } if ($searches >= 2 && $ctr >= 40) { return [ 'issue' => 'High click-through topic', 'priority' => 'medium', 'priority_score' => 140 + $searches, 'suggested_action' => 'Consider expanding this topic', ]; } return [ 'issue' => null, 'priority' => 'low', 'priority_score' => 0, 'suggested_action' => 'Monitor search intent', ]; } /** * @param array $row * @return array|null */ private function classifyPromptInsight(array $row): ?array { if ((int) $row['upgrade_clicks'] >= 3 || ((float) $row['upgrade_rate'] >= 15 && (int) $row['premium_preview_views'] >= 5)) { return [ 'issue' => 'High upgrade interest', 'priority' => 'high', 'priority_score' => 300 + (int) $row['upgrade_clicks'], 'suggested_action' => 'Create premium pack or advanced lesson around this topic', ]; } if ((int) $row['views'] >= 120 && (float) $row['copy_rate'] < 8) { return [ 'issue' => 'High views, low copies', 'priority' => 'medium', 'priority_score' => 230 + (int) $row['views'], 'suggested_action' => 'Improve prompt quality, preview image, title, or negative prompt', ]; } if ((int) $row['views'] <= 30 && (int) $row['prompt_copies'] >= 3 && (float) $row['copy_rate'] >= 35) { return [ 'issue' => 'Low views, high copy rate', 'priority' => 'medium', 'priority_score' => 210 + (int) $row['prompt_copies'], 'suggested_action' => 'Feature this prompt, improve SEO, add to related content', ]; } if ((int) $row['saves'] >= 5 && (int) $row['prompt_copies'] < (int) $row['saves']) { return [ 'issue' => 'High saves, low copies', 'priority' => 'medium', 'priority_score' => 190 + (int) $row['saves'], 'suggested_action' => 'Add examples, variations, or usage notes', ]; } if ((int) $row['prompt_copies'] >= 8 && (float) $row['like_rate'] < 5) { return [ 'issue' => 'High copies, low likes', 'priority' => 'low', 'priority_score' => 160 + (int) $row['prompt_copies'], 'suggested_action' => 'Improve like/save UI visibility or ask for feedback', ]; } return null; } /** * @param array $row * @return array|null */ private function classifyLessonDropoff(array $row): ?array { if ((int) $row['starts'] >= 12 && (float) $row['completion_rate'] < 35) { return [ 'issue' => 'High starts, low completions', 'priority' => 'high', 'priority_score' => 300 + (int) $row['starts'], 'suggested_action' => 'Lesson may be too long, confusing, or missing examples', ]; } if ((int) $row['views'] >= 80 && (float) $row['start_rate'] < 18) { return [ 'issue' => 'High views, low starts', 'priority' => 'medium', 'priority_score' => 230 + (int) $row['views'], 'suggested_action' => 'Improve lesson intro, title, excerpt, or call-to-action', ]; } if ((int) $row['completions'] >= 8 && (int) $row['views'] <= 35) { return [ 'issue' => 'High completions, low views', 'priority' => 'medium', 'priority_score' => 200 + (int) $row['completions'], 'suggested_action' => 'Promote this lesson more', ]; } if ((int) $row['upgrade_clicks'] >= 3 || ((float) $row['upgrade_rate'] >= 12 && (int) $row['premium_preview_views'] >= 5)) { return [ 'issue' => 'Upgrade interest', 'priority' => 'medium', 'priority_score' => 180 + (int) $row['upgrade_clicks'], 'suggested_action' => 'This lesson may be useful as a subscription conversion entry point', ]; } return null; } /** * @param array $row * @return array|null */ private function classifyCourseHealth(array $row): ?array { if ((int) $row['starts'] >= 10 && (float) $row['completion_rate'] < 35) { return [ 'issue' => 'Low course completion rate', 'priority' => 'high', 'priority_score' => 300 + (int) $row['starts'], 'suggested_action' => 'Add shorter lessons, move the strongest lesson earlier, or improve examples', ]; } if ((int) $row['views'] >= 60 && (float) $row['start_rate'] < 18) { return [ 'issue' => 'Low course start rate', 'priority' => 'medium', 'priority_score' => 220 + (int) $row['views'], 'suggested_action' => 'Improve course landing page, cover image, or course positioning', ]; } if ((int) $row['upgrade_clicks'] >= 3) { return [ 'issue' => 'Premium follow-up opportunity', 'priority' => 'medium', 'priority_score' => 190 + (int) $row['upgrade_clicks'], 'suggested_action' => 'Add a premium follow-up course around this topic', ]; } if ((int) $row['completions'] >= 8 && (float) $row['completion_rate'] >= 65) { return [ 'issue' => 'Expansion candidate', 'priority' => 'medium', 'priority_score' => 170 + (int) $row['completions'], 'suggested_action' => 'Expand this course with advanced follow-up material', ]; } return null; } /** * @param array $row * @return array|null */ private function classifyPremiumInterest(array $row): ?array { if ((int) $row['upgrade_clicks'] >= 3) { return [ 'issue' => 'Strong premium candidate', 'priority' => 'high', 'priority_score' => 300 + (int) $row['upgrade_clicks'], 'suggested_action' => 'Create advanced premium content around this topic', ]; } if ((int) $row['premium_preview_views'] >= 15 && (int) $row['upgrade_clicks'] <= 1) { return [ 'issue' => 'Weak premium teaser', 'priority' => 'medium', 'priority_score' => 190 + (int) $row['premium_preview_views'], 'suggested_action' => 'Improve teaser copy, preview images, or value proposition', ]; } return null; } /** * @param Collection> $rows * @return Collection> */ private function dedupeByKey(Collection $rows, string $key): Collection { $seen = []; return $rows->filter(function (array $row) use (&$seen, $key): bool { $value = (string) ($row[$key] ?? ''); if ($value === '' || isset($seen[$value])) { return false; } $seen[$value] = true; return true; }); } }