resolveDateRange($request); $promptLibraryCurrent = $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to); [$previousFrom, $previousTo] = $this->previousRange($from, $to); $promptLibraryPrevious = $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $previousFrom, $previousTo); $summary = $this->metricsQuery($from, $to) ->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(user_views) as user_views, sum(guest_views) as guest_views, sum(subscriber_views) as subscriber_views, sum(prompt_copies) as prompt_copies, sum(likes) as likes, sum(saves) as saves, sum(completions) as completions, sum(starts) as starts, sum(upgrade_clicks) as upgrade_clicks') ->first(); return Inertia::render('Admin/Academy/AnalyticsOverview', [ 'nav' => $this->nav(), 'range' => $this->rangePayload($range, $from, $to), 'stats' => [ 'views' => (int) ($summary?->views ?? 0), 'uniqueVisitors' => (int) ($summary?->unique_visitors ?? 0), 'userViews' => (int) ($summary?->user_views ?? 0), 'guestViews' => (int) ($summary?->guest_views ?? 0), 'subscriberViews' => (int) ($summary?->subscriber_views ?? 0), 'promptCopies' => (int) ($summary?->prompt_copies ?? 0), 'likes' => (int) ($summary?->likes ?? 0), 'saves' => (int) ($summary?->saves ?? 0), 'lessonCompletions' => (int) ($summary?->completions ?? 0), 'courseStarts' => (int) ($summary?->starts ?? 0), 'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0), ], 'promptLibraryTrend' => [ 'current' => $promptLibraryCurrent, 'previous' => $promptLibraryPrevious, 'deltas' => [ 'views' => $this->percentDelta((int) $promptLibraryCurrent['views'], (int) $promptLibraryPrevious['views']), 'uniqueVisitors' => $this->percentDelta((int) $promptLibraryCurrent['uniqueVisitors'], (int) $promptLibraryPrevious['uniqueVisitors']), 'engagedViews' => $this->percentDelta((int) $promptLibraryCurrent['engagedViews'], (int) $promptLibraryPrevious['engagedViews']), 'engagementRate' => $this->percentDelta((float) $promptLibraryCurrent['engagementRate'], (float) $promptLibraryPrevious['engagementRate']), ], 'range' => [ 'current' => ['from' => $from->toDateString(), 'to' => $to->toDateString()], 'previous' => ['from' => $previousFrom->toDateString(), 'to' => $previousTo->toDateString()], ], ], 'popularPromptPeriodUsage' => $this->popularPromptPeriodUsage($from, $to), 'topContent' => $this->serializeContentRows($this->popularity->topContent($from, $to, 8)), 'topWeek' => $this->serializeContentRows($this->popularity->topContent(now()->subDays(6)->startOfDay(), now()->endOfDay(), 8)), ]); } public function content(Request $request): Response { return $this->renderContentPage($request, null, 'Content performance', 'Cross-module performance across prompts, lessons, courses, packs, and challenges.'); } public function prompts(Request $request): Response { return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT, 'Prompt analytics', 'Copy-heavy prompt performance, save rates, and upgrade interest.'); } public function promptLibrary(Request $request): Response { return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT_LIBRARY, 'Prompt library analytics', 'Discovery and engagement on the public /academy/prompts library page.'); } public function lessons(Request $request): Response { return $this->renderContentPage($request, AcademyAnalyticsContentType::LESSON, 'Lesson analytics', 'Lesson engagement, starts, completions, and drop-off signals.'); } public function courses(Request $request): Response { return $this->renderContentPage($request, AcademyAnalyticsContentType::COURSE, 'Course analytics', 'Course views, starts, completion progress, and upgrade intent.'); } public function search(Request $request): Response { [$from, $to, $range] = $this->resolveDateRange($request); $searchQuery = AcademySearchLog::query()->whereBetween('created_at', [$from, $to]); $searchLogs = (clone $searchQuery)->latest('created_at')->limit(500)->get(); return Inertia::render('Admin/Academy/AnalyticsSearch', [ 'nav' => $this->nav(), 'range' => $this->rangePayload($range, $from, $to), 'summary' => [ 'searches' => (int) (clone $searchQuery)->count(), 'zeroResultSearches' => (int) (clone $searchQuery)->where('results_count', 0)->count(), 'loggedInSearches' => (int) (clone $searchQuery)->where('is_logged_in', true)->count(), 'subscriberSearches' => (int) (clone $searchQuery)->where('is_subscriber', true)->count(), 'searchesWithClicks' => (int) (clone $searchQuery)->whereNotNull('clicked_content_id')->count(), ], 'topSearches' => (clone $searchQuery) ->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(results_count = 0) as zero_result_hits, avg(results_count) as avg_results, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks') ->groupBy('normalized_query') ->orderByDesc('searches') ->limit(20) ->get() ->map(fn ($row): array => [ 'query' => (string) ($row->query ?: $row->normalized_query), 'normalized_query' => (string) $row->normalized_query, 'searches' => (int) $row->searches, 'zero_result_hits' => (int) $row->zero_result_hits, 'avg_results' => round((float) $row->avg_results, 1), 'clicks' => (int) ($row->clicks ?? 0), 'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0, ]) ->all(), 'zeroResults' => (clone $searchQuery) ->selectRaw('normalized_query, max(query) as query, count(*) as searches') ->where('results_count', 0) ->groupBy('normalized_query') ->orderByDesc('searches') ->limit(20) ->get() ->map(fn ($row): array => [ 'query' => (string) ($row->query ?: $row->normalized_query), 'searches' => (int) $row->searches, ]) ->all(), 'lowClickThroughSearches' => (clone $searchQuery) ->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results') ->groupBy('normalized_query') ->havingRaw('count(*) >= 2') ->orderByRaw('case when count(*) = 0 then 1 else (sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) end asc') ->limit(20) ->get() ->map(fn ($row): array => [ 'query' => (string) ($row->query ?: $row->normalized_query), 'searches' => (int) $row->searches, 'clicks' => (int) ($row->clicks ?? 0), 'avg_results' => round((float) $row->avg_results, 1), 'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0, ]) ->all(), 'highestClickThroughSearches' => (clone $searchQuery) ->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results') ->groupBy('normalized_query') ->havingRaw('count(*) >= 2') ->orderByRaw('(sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) desc') ->limit(20) ->get() ->map(fn ($row): array => [ 'query' => (string) ($row->query ?: $row->normalized_query), 'searches' => (int) $row->searches, 'clicks' => (int) ($row->clicks ?? 0), 'avg_results' => round((float) $row->avg_results, 1), 'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0, ]) ->all(), 'searchesWithResultsNoClicks' => (clone $searchQuery) ->selectRaw('normalized_query, max(query) as query, count(*) as searches, avg(results_count) as avg_results') ->where('results_count', '>', 0) ->whereNull('clicked_content_id') ->groupBy('normalized_query') ->orderByDesc('searches') ->limit(20) ->get() ->map(fn ($row): array => [ 'query' => (string) ($row->query ?: $row->normalized_query), 'searches' => (int) $row->searches, 'avg_results' => round((float) $row->avg_results, 1), 'clicks' => 0, 'click_through_rate' => 0, ]) ->all(), 'topClickedResults' => (clone $searchQuery) ->selectRaw('clicked_content_type, clicked_content_id, count(*) as clicks') ->whereNotNull('clicked_content_type') ->whereNotNull('clicked_content_id') ->groupBy('clicked_content_type', 'clicked_content_id') ->orderByDesc('clicks') ->limit(20) ->get() ->map(fn ($row): array => [ 'title' => $this->resolver->title((string) $row->clicked_content_type, (int) $row->clicked_content_id), 'content_type' => (string) $row->clicked_content_type, 'content_id' => (int) $row->clicked_content_id, 'clicks' => (int) $row->clicks, ]) ->all(), 'filterUsage' => $this->summarizeSearchFilters($searchLogs), 'recentSearches' => (clone $searchQuery) ->latest('created_at') ->limit(25) ->get() ->map(fn (AcademySearchLog $log): array => [ 'query' => (string) $log->query, 'results_count' => (int) $log->results_count, 'logged_in' => (bool) $log->is_logged_in, 'subscriber' => (bool) $log->is_subscriber, 'clicked_content_type' => $log->clicked_content_type, 'has_click' => $log->clicked_content_id !== null, 'created_at' => $log->created_at?->toISOString(), ]) ->all(), ]); } public function intelligence(Request $request): Response { [$from, $to, $range] = $this->resolveDateRange($request, '30d'); $filters = [ 'from' => $from, 'to' => $to, 'limit' => 25, ]; return Inertia::render('Admin/Academy/AnalyticsIntelligence', [ 'nav' => $this->nav(), 'range' => $this->rangePayload($range, $from, $to), 'contentOpportunities' => $this->intelligence->getContentOpportunities($filters), 'searchGaps' => $this->intelligence->getSearchGaps($filters), 'promptInsights' => $this->intelligence->getPromptInsights($filters), 'lessonDropoffs' => $this->intelligence->getLessonDropoffs($filters), 'courseHealth' => $this->intelligence->getCourseHealth($filters), 'premiumInterest' => $this->intelligence->getPremiumInterest($filters), 'editorialRecommendations' => $this->intelligence->getEditorialRecommendations($filters), ]); } /** * @param Collection $logs * @return list> */ private function summarizeSearchFilters(Collection $logs): array { $counts = []; foreach ($logs as $log) { $filters = is_array($log->filters) ? $log->filters : []; foreach ($filters as $key => $value) { if ($value === null || $value === '' || $key === 'q') { continue; } $values = is_array($value) ? $value : [$value]; foreach ($values as $rawValue) { $label = trim((string) $rawValue); if ($label === '') { continue; } $bucket = sprintf('%s:%s', $key, $label); $counts[$bucket] = [ 'filter' => (string) $key, 'value' => $label, 'uses' => (int) (($counts[$bucket]['uses'] ?? 0) + 1), ]; } } } usort($counts, static fn (array $left, array $right): int => $right['uses'] <=> $left['uses']); return array_slice(array_values($counts), 0, 20); } public function funnel(Request $request): Response { [$from, $to, $range] = $this->resolveDateRange($request); $summary = $this->metricsQuery($from, $to) ->selectRaw('sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(starts) as starts, sum(completions) as completions') ->first(); $bestConverters = $this->metricsQuery($from, $to) ->selectRaw('content_type, content_id, sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(conversion_score) as conversion_score') ->groupBy('content_type', 'content_id') ->havingRaw('sum(upgrade_clicks) > 0') ->orderByDesc('conversion_score') ->limit(12) ->get(); return Inertia::render('Admin/Academy/AnalyticsFunnel', [ 'nav' => $this->nav(), 'range' => $this->rangePayload($range, $from, $to), 'summary' => [ 'academyVisitors' => (int) ($summary?->unique_visitors ?? 0), 'premiumPreviewViews' => (int) ($summary?->premium_preview_views ?? 0), 'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0), 'starts' => (int) ($summary?->starts ?? 0), 'completions' => (int) ($summary?->completions ?? 0), 'checkoutStarts' => 0, 'subscriptions' => 0, ], 'bestConverters' => $this->serializeContentRows($bestConverters, includeConversion: true), ]); } private function renderContentPage(Request $request, ?string $forcedContentType, string $title, string $subtitle): Response { [$from, $to, $range] = $this->resolveDateRange($request); $sort = (string) $request->query('sort', 'popularity_score'); $direction = strtolower((string) $request->query('direction', 'desc')) === 'asc' ? 'asc' : 'desc'; $access = trim((string) $request->query('access', '')); $contentType = $forcedContentType ?: (trim((string) $request->query('content_type', '')) ?: null); $query = $this->metricsQuery($from, $to) ->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(starts) as starts, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(popularity_score) as popularity_score, sum(conversion_score) as conversion_score') ->groupBy('content_type', 'content_id'); if ($contentType !== null) { $query->where('content_type', $contentType); } $rows = $query->get(); $serializedRows = $this->serializeContentRows($rows, includeConversion: true) ->filter(function (array $row) use ($access): bool { if ($access === '') { return true; } return strtolower((string) ($row['access_level'] ?? '')) === strtolower($access); }) ->sortBy($sort, SORT_REGULAR, $direction === 'desc') ->values() ->all(); return Inertia::render('Admin/Academy/AnalyticsContent', [ 'nav' => $this->nav(), 'range' => $this->rangePayload($range, $from, $to), 'title' => $title, 'subtitle' => $subtitle, 'filters' => [ 'sort' => $sort, 'direction' => $direction, 'access' => $access, 'content_type' => $contentType, ], 'summary' => $contentType === AcademyAnalyticsContentType::PROMPT_LIBRARY ? $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to) : null, 'rows' => $serializedRows, 'contentTypeOptions' => [ ['value' => '', 'label' => 'All content'], ['value' => AcademyAnalyticsContentType::PROMPT_LIBRARY, 'label' => 'Prompt library'], ['value' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY, 'label' => 'Prompt pack library'], ['value' => AcademyAnalyticsContentType::PROMPT, 'label' => 'Prompts'], ['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'], ['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'], ['value' => AcademyAnalyticsContentType::PROMPT_PACK, 'label' => 'Prompt packs'], ['value' => AcademyAnalyticsContentType::CHALLENGE, 'label' => 'Challenges'], ], 'sortOptions' => [ ['value' => 'views', 'label' => 'Views'], ['value' => 'unique_visitors', 'label' => 'Unique visitors'], ['value' => 'likes', 'label' => 'Likes'], ['value' => 'saves', 'label' => 'Saves'], ['value' => 'prompt_copies', 'label' => 'Copies'], ['value' => 'completions', 'label' => 'Completions'], ['value' => 'upgrade_clicks', 'label' => 'Upgrade clicks'], ['value' => 'popularity_score', 'label' => 'Popularity score'], ['value' => 'conversion_score', 'label' => 'Conversion score'], ], ]); } private function metricsQuery(Carbon $from, Carbon $to) { return AcademyContentMetricDaily::query() ->whereBetween('date', [$from->copy()->startOfDay(), $to->copy()->endOfDay()]); } /** * @return array */ private function contentSummary(string $contentType, Carbon $from, Carbon $to): array { $query = $this->metricsQuery($from, $to) ->where('content_type', $contentType); if (! AcademyAnalyticsContentType::requiresContentId($contentType)) { $query->whereNull('content_id'); } $summary = $query ->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(scroll_50) as scroll_50, sum(scroll_75) as scroll_75, sum(scroll_100) as scroll_100, avg(avg_engaged_seconds) as avg_engaged_seconds, sum(popularity_score) as popularity_score') ->first(); $uniqueVisitors = max(0, (int) ($summary?->unique_visitors ?? 0)); $engagedViews = max(0, (int) ($summary?->engaged_views ?? 0)); $scroll100 = max(0, (int) ($summary?->scroll_100 ?? 0)); return [ 'views' => max(0, (int) ($summary?->views ?? 0)), 'uniqueVisitors' => $uniqueVisitors, 'engagedViews' => $engagedViews, 'scroll50' => max(0, (int) ($summary?->scroll_50 ?? 0)), 'scroll75' => max(0, (int) ($summary?->scroll_75 ?? 0)), 'scroll100' => $scroll100, 'avgEngagedSeconds' => round((float) ($summary?->avg_engaged_seconds ?? 0), 1), 'popularityScore' => round((float) ($summary?->popularity_score ?? 0), 2), 'engagementRate' => $uniqueVisitors > 0 ? round(($engagedViews / $uniqueVisitors) * 100, 1) : 0.0, 'deepScrollRate' => $uniqueVisitors > 0 ? round(($scroll100 / $uniqueVisitors) * 100, 1) : 0.0, ]; } /** * @return array{0: Carbon, 1: Carbon} */ private function previousRange(Carbon $from, Carbon $to): array { $days = $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1; return [ $from->copy()->subDays($days)->startOfDay(), $from->copy()->subDay()->endOfDay(), ]; } private function percentDelta(int|float $current, int|float $previous): ?float { if ((float) $previous === 0.0) { return (float) $current === 0.0 ? 0.0 : null; } return round((((float) $current - (float) $previous) / (float) $previous) * 100, 1); } /** * @return array{totalViews:int,totalVisitors:int,periods:list>} */ private function popularPromptPeriodUsage(Carbon $from, Carbon $to): array { $events = AcademyEvent::query() ->whereBetween('occurred_at', [$from, $to]) ->where('event_type', AcademyAnalyticsEventType::PAGE_VIEW) ->where('content_type', AcademyAnalyticsContentType::PROMPT_POPULAR) ->get(['visitor_id', 'metadata']); $summary = []; $totalViews = 0; $visitorBuckets = []; foreach ($events as $event) { $metadata = is_array($event->metadata) ? $event->metadata : []; $period = trim((string) ($metadata['period'] ?? '')); if ($period === '') { continue; } $days = max(0, (int) ($metadata['period_days'] ?? 0)); if (! isset($summary[$period])) { $summary[$period] = [ 'period' => $period, 'label' => sprintf('%s days', $days > 0 ? $days : (int) preg_replace('/\D+/', '', $period)), 'views' => 0, 'uniqueVisitors' => 0, 'share' => 0.0, 'days' => $days, ]; $visitorBuckets[$period] = []; } $summary[$period]['views']++; $totalViews++; $visitorId = trim((string) ($event->visitor_id ?? '')); if ($visitorId !== '') { $visitorBuckets[$period][$visitorId] = true; } } $totalVisitors = 0; foreach ($summary as $period => &$row) { $uniqueVisitors = count($visitorBuckets[$period] ?? []); $row['uniqueVisitors'] = $uniqueVisitors; $row['share'] = $totalViews > 0 ? round((((int) $row['views']) / $totalViews) * 100, 1) : 0.0; $totalVisitors += $uniqueVisitors; } unset($row); usort($summary, static function (array $left, array $right): int { if ((int) $right['views'] === (int) $left['views']) { return ((int) $left['days']) <=> ((int) $right['days']); } return ((int) $right['views']) <=> ((int) $left['views']); }); return [ 'totalViews' => $totalViews, 'totalVisitors' => $totalVisitors, 'periods' => array_values($summary), ]; } /** * @param Collection $rows * @return Collection> */ private function serializeContentRows(Collection $rows, bool $includeConversion = false): Collection { return $rows->map(function ($row) use ($includeConversion): array { $contentType = (string) $row->content_type; $contentId = $row->content_id ? (int) $row->content_id : null; $title = $this->resolver->title($contentType, $contentId); $accessLevel = $this->resolver->accessLevel($contentType, $contentId); $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)); $premiumPreviewViews = max(0, (int) ($row->premium_preview_views ?? 0)); $upgradeClicks = max(0, (int) ($row->upgrade_clicks ?? 0)); return [ 'content_type' => $contentType, 'content_type_label' => (string) Str::of(str_replace('academy_', '', $contentType))->replace('_', ' ')->headline(), 'content_id' => $contentId, 'title' => $title, 'access_level' => $accessLevel, 'views' => (int) ($row->views ?? 0), 'unique_visitors' => $uniqueVisitors, 'engaged_views' => (int) ($row->engaged_views ?? 0), 'likes' => $likes, 'saves' => $saves, 'prompt_copies' => $promptCopies, 'starts' => $starts, 'completions' => $completions, 'upgrade_clicks' => $upgradeClicks, 'popularity_score' => round((float) ($row->popularity_score ?? 0), 2), 'conversion_score' => round((float) ($row->conversion_score ?? 0), 2), 'copy_rate' => $uniqueVisitors > 0 ? round(($promptCopies / $uniqueVisitors) * 100, 1) : 0, 'save_rate' => $uniqueVisitors > 0 ? round(($saves / $uniqueVisitors) * 100, 1) : 0, 'like_rate' => $uniqueVisitors > 0 ? round(($likes / $uniqueVisitors) * 100, 1) : 0, 'completion_rate' => $starts > 0 ? round(($completions / $starts) * 100, 1) : 0, 'upgrade_rate' => max(1, $premiumPreviewViews) > 0 ? round(($upgradeClicks / max(1, $premiumPreviewViews)) * 100, 1) : 0, 'trend' => ((float) ($row->popularity_score ?? 0)) >= 100 ? 'High momentum' : (((float) ($row->popularity_score ?? 0)) >= 25 ? 'Building' : 'Early'), 'include_conversion' => $includeConversion, ]; }); } /** * @return array{0: Carbon, 1: Carbon, 2: string} */ private function resolveDateRange(Request $request, string $defaultRange = '7d'): array { $range = trim((string) $request->query('range', $defaultRange)); return match ($range) { 'today' => [now()->startOfDay(), now()->endOfDay(), 'today'], 'yesterday' => [now()->subDay()->startOfDay(), now()->subDay()->endOfDay(), 'yesterday'], '30d' => [now()->subDays(29)->startOfDay(), now()->endOfDay(), '30d'], '90d' => [now()->subDays(89)->startOfDay(), now()->endOfDay(), '90d'], 'custom' => [ Carbon::parse((string) $request->query('from', now()->subDays(6)->toDateString()))->startOfDay(), Carbon::parse((string) $request->query('to', now()->toDateString()))->endOfDay(), 'custom', ], default => [now()->subDays(6)->startOfDay(), now()->endOfDay(), '7d'], }; } /** * @return list> */ private function nav(): array { return [ ['label' => 'Overview', 'href' => route('admin.academy.analytics.overview')], ['label' => 'Intelligence', 'href' => route('admin.academy.analytics.intelligence')], ['label' => 'Content', 'href' => route('admin.academy.analytics.content')], ['label' => 'Prompt Library', 'href' => route('admin.academy.analytics.prompt-library')], ['label' => 'Prompts', 'href' => route('admin.academy.analytics.prompts')], ['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')], ['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')], ['label' => 'Search', 'href' => route('admin.academy.analytics.search')], ['label' => 'Funnel', 'href' => route('admin.academy.analytics.funnel')], ]; } /** * @return array */ private function rangePayload(string $activeRange, Carbon $from, Carbon $to): array { return [ 'active' => $activeRange, 'from' => $from->toDateString(), 'to' => $to->toDateString(), 'options' => [ ['value' => 'today', 'label' => 'Today'], ['value' => 'yesterday', 'label' => 'Yesterday'], ['value' => '7d', 'label' => 'Last 7 days'], ['value' => '30d', 'label' => 'Last 30 days'], ['value' => '90d', 'label' => 'Last 90 days'], ['value' => 'custom', 'label' => 'Custom range'], ], ]; } }