Files
SkinbaseNova/app/Http/Controllers/Settings/AcademyAdminAnalyticsController.php

629 lines
30 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyContentIntelligenceService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyAdminAnalyticsController extends Controller
{
public function __construct(
private readonly AcademyPopularityService $popularity,
private readonly AcademyAnalyticsContentResolver $resolver,
private readonly AcademyContentIntelligenceService $intelligence,
) {}
public function overview(Request $request): Response
{
[$from, $to, $range] = $this->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<int, AcademySearchLog> $logs
* @return list<array<string, int|string>>
*/
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<string, int|float>
*/
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<array<string,int|float|string>>}
*/
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<int, mixed> $rows
* @return Collection<int, array<string, mixed>>
*/
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<array<string, string|bool>>
*/
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<string, mixed>
*/
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'],
],
];
}
}