471 lines
23 KiB
PHP
471 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Settings;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\AcademyContentMetricDaily;
|
|
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 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);
|
|
|
|
$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),
|
|
],
|
|
'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 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,
|
|
],
|
|
'rows' => $serializedRows,
|
|
'contentTypeOptions' => [
|
|
['value' => '', 'label' => 'All content'],
|
|
['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->toDateString(), $to->toDateString()]);
|
|
}
|
|
|
|
/**
|
|
* @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' => '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'],
|
|
],
|
|
];
|
|
}
|
|
}
|