7, '30d' => 30, 'all' => null, ]; private const PORTFOLIO_RANGE_WINDOWS = [ '30d' => 30, 'all' => null, ]; public function allowedEventTypes(): array { return [ self::EVENT_SOURCE_IMPRESSION, self::EVENT_VIEWED, self::EVENT_SOURCE_CLICKED, self::EVENT_CTA_CLICKED, self::EVENT_SECTION_CLICKED, self::EVENT_ENTITY_CLICKED, self::EVENT_SUBMISSION_STARTED, self::EVENT_SUBMISSION_CREATED, self::EVENT_SUBMISSION_APPROVED, self::EVENT_SUBMISSION_REMOVED, self::EVENT_SUBMISSION_BLOCKED, self::EVENT_SUBMISSION_FEATURED, self::EVENT_CHALLENGE_CTA_CLICKED, self::EVENT_REWARD_GRANTED, ]; } public function allowedSourceSurfaces(): array { return [ self::SOURCE_HOMEPAGE_SPOTLIGHT, self::SOURCE_HOMEPAGE_WORLDS_RAIL, self::SOURCE_WORLDS_INDEX, self::SOURCE_NAVIGATION, self::SOURCE_UPLOAD_FLOW, self::SOURCE_CHALLENGE_PAGE, self::SOURCE_NEWS_ARTICLE, self::SOURCE_PROFILE, self::SOURCE_DIRECT, self::SOURCE_UNKNOWN, ]; } public function recordEvent(Request $request, array $payload): void { $world = World::query() ->select(['id', 'slug', 'type', 'recurrence_key', 'edition_year']) ->findOrFail((int) $payload['world_id']); $user = $request->user(); WorldAnalyticsEvent::query()->create([ 'world_id' => (int) $world->id, 'event_type' => (string) $payload['event_type'], 'world_slug' => (string) $world->slug, 'world_type' => (string) $world->type, 'recurrence_key' => $this->nullableString($world->recurrence_key), 'edition_year' => $world->edition_year ? (int) $world->edition_year : null, 'section_key' => $this->nullableString($payload['section_key'] ?? null), 'cta_key' => $this->nullableString($payload['cta_key'] ?? null), 'entity_type' => $this->nullableString($payload['entity_type'] ?? null), 'entity_id' => isset($payload['entity_id']) ? (int) $payload['entity_id'] : null, 'entity_title' => $this->nullableString($payload['entity_title'] ?? null), 'challenge_id' => isset($payload['challenge_id']) ? (int) $payload['challenge_id'] : null, 'source_surface' => $this->nullableString($payload['source_surface'] ?? null), 'source_detail' => $this->nullableString($payload['source_detail'] ?? null), 'viewer_type' => $user ? 'user' : 'guest', 'user_id' => $user ? (int) $user->id : null, 'visitor_key' => $this->resolveVisitorKey($request, $user, (string) ($payload['visitor_token'] ?? '')), 'meta' => $this->sanitizeMeta($payload['meta'] ?? []), 'occurred_at' => now(), ]); } public function recordSubmissionLifecycle(WorldSubmission $submission, string $eventType, ?User $actor = null, ?string $sourceSurface = null, array $meta = []): void { $submission->loadMissing(['world', 'artwork']); $world = $submission->world; if (! $world) { return; } WorldAnalyticsEvent::query()->create([ 'world_id' => (int) $world->id, 'event_type' => $eventType, 'world_slug' => (string) $world->slug, 'world_type' => (string) $world->type, 'recurrence_key' => $this->nullableString($world->recurrence_key), 'edition_year' => $world->edition_year ? (int) $world->edition_year : null, 'section_key' => 'community_submissions', 'entity_type' => 'artwork', 'entity_id' => $submission->artwork_id ? (int) $submission->artwork_id : null, 'entity_title' => $this->nullableString($submission->artwork?->title), 'source_surface' => $this->nullableString($sourceSurface), 'viewer_type' => $actor ? 'user' : 'guest', 'user_id' => $actor ? (int) $actor->id : null, 'visitor_key' => $actor ? hash('sha256', 'user:' . $actor->id) : hash('sha256', 'system:world_submission'), 'meta' => $this->sanitizeMeta(array_merge([ 'submission_id' => (int) $submission->id, 'status' => (string) $submission->status, 'is_featured' => (bool) $submission->is_featured, 'mode_snapshot' => (string) ($submission->mode_snapshot ?? ''), ], $meta)), 'occurred_at' => now(), ]); } public function recordRewardGrant(WorldRewardGrant $grant): void { $grant->loadMissing(['world', 'artwork']); $world = $grant->world; if (! $world) { return; } WorldAnalyticsEvent::query()->create([ 'world_id' => (int) $world->id, 'event_type' => self::EVENT_REWARD_GRANTED, 'world_slug' => (string) $world->slug, 'world_type' => (string) $world->type, 'recurrence_key' => $this->nullableString($world->recurrence_key), 'edition_year' => $world->edition_year ? (int) $world->edition_year : null, 'section_key' => 'rewards', 'entity_type' => 'artwork', 'entity_id' => $grant->artwork_id ? (int) $grant->artwork_id : null, 'entity_title' => $this->nullableString($grant->artwork?->title), 'viewer_type' => $grant->user_id ? 'user' : 'guest', 'user_id' => $grant->user_id ? (int) $grant->user_id : null, 'visitor_key' => $grant->user_id ? hash('sha256', 'user:' . $grant->user_id) : hash('sha256', 'system:world_reward'), 'meta' => $this->sanitizeMeta([ 'reward_type' => $grant->reward_type->value, 'grant_source' => (string) $grant->grant_source, 'world_submission_id' => $grant->world_submission_id ? (int) $grant->world_submission_id : null, ]), 'occurred_at' => now(), ]); } public function studioReport(World $world): array { $ranges = []; foreach (self::RANGE_WINDOWS as $key => $days) { $ranges[$key] = $this->rangePayload($world, $days ? now()->subDays($days) : null); } return [ 'default_range' => '30d', 'range_options' => [ ['value' => '7d', 'label' => 'Last 7 days'], ['value' => '30d', 'label' => 'Last 30 days'], ['value' => 'all', 'label' => 'Lifetime'], ], 'ranges' => $ranges, 'edition_comparison' => $this->editionComparisonPayload($world), ]; } public function portfolioReport(): array { $ranges = []; foreach (self::PORTFOLIO_RANGE_WINDOWS as $key => $days) { $ranges[$key] = $this->portfolioRangePayload($days ? now()->subDays($days) : null); } return [ 'default_range' => '30d', 'range_options' => [ ['value' => '30d', 'label' => 'Last 30 days'], ['value' => 'all', 'label' => 'Lifetime'], ], 'ranges' => $ranges, ]; } public function sourceSurfaceLabel(?string $surface): string { return match ((string) $surface) { self::SOURCE_HOMEPAGE_SPOTLIGHT => 'Homepage spotlight', self::SOURCE_HOMEPAGE_WORLDS_RAIL => 'Homepage worlds rail', self::SOURCE_WORLDS_INDEX => 'Worlds index', self::SOURCE_NAVIGATION => 'Navigation', self::SOURCE_UPLOAD_FLOW => 'Upload flow', self::SOURCE_CHALLENGE_PAGE => 'Challenge page', self::SOURCE_NEWS_ARTICLE => 'News article', self::SOURCE_PROFILE => 'Profile', self::SOURCE_DIRECT => 'Direct', default => 'Unknown', }; } private function rangePayload(World $world, ?Carbon $start): array { $eventCounts = $this->eventCounts($world, $start); $currentSubmissionCounts = $this->currentSubmissionCounts($world); $submissionActivity = $this->submissionActivityCounts($world, $start, $eventCounts); $rewardCounts = $this->rewardCounts($world, $start); $sources = $this->sourceBreakdown($world, $start); $sectionPerformance = $this->sectionPerformance($world, $start); $entityPerformance = $this->entityPerformance($world, $start); $ctaPerformance = $this->ctaPerformance($world, $start); $challengeMetrics = $this->challengeMetrics($world, $start, $eventCounts); $promotionImpressions = (int) ($eventCounts[self::EVENT_SOURCE_IMPRESSION] ?? 0); $sourceClicks = (int) ($eventCounts[self::EVENT_SOURCE_CLICKED] ?? 0); $views = (int) ($eventCounts[self::EVENT_VIEWED] ?? 0); $uniqueVisitors = $this->uniqueViewers($world, $start); $ctaClicks = (int) ($eventCounts[self::EVENT_CTA_CLICKED] ?? 0); $topSource = collect($sources)->sortByDesc('views')->first(); $topSection = collect($sectionPerformance)->sortByDesc('clicks')->first(); $topEntity = collect($entityPerformance)->sortByDesc('clicks')->first(); return [ 'summary' => [ 'views' => $views, 'unique_visitors' => $uniqueVisitors, 'promotion_impressions' => $promotionImpressions, 'cta_clicks' => $ctaClicks, 'submissions' => $submissionActivity['submitted'], 'approved_live_participations' => $currentSubmissionCounts['live'], 'featured_participations' => $currentSubmissionCounts['featured'], 'reward_grants' => $rewardCounts['total'], 'challenge_clicks' => $challengeMetrics['total_clicks'], 'approval_rate' => $submissionActivity['approval_rate'], 'promotion_clickthrough_rate' => $promotionImpressions > 0 ? round($sourceClicks / $promotionImpressions, 4) : 0.0, 'view_to_submission_conversion' => $submissionActivity['view_to_submission_conversion'], 'top_source_surface' => $topSource ? [ 'key' => $topSource['source_surface'], 'label' => $this->sourceSurfaceLabel($topSource['source_surface']), 'views' => $topSource['views'], 'impressions' => $topSource['impressions'], 'clickthrough_rate' => $topSource['clickthrough_rate'], ] : null, 'top_clicked_section' => $topSection, 'top_clicked_entity' => $topEntity, ], 'traffic' => [ 'views' => $views, 'unique_visitors' => $uniqueVisitors, 'trend' => $this->trafficTrend($world, $start), ], 'sources' => $sources, 'cta_performance' => $ctaPerformance, 'section_performance' => $sectionPerformance, 'entity_performance' => $entityPerformance, 'participation' => [ ...$currentSubmissionCounts, ...$submissionActivity, ], 'challenge' => $challengeMetrics, 'rewards' => $rewardCounts, ]; } private function eventCounts(World $world, ?Carbon $start): array { return $this->baseEventQuery($world, $start) ->select('event_type', DB::raw('COUNT(*) as total')) ->groupBy('event_type') ->pluck('total', 'event_type') ->map(fn ($count): int => (int) $count) ->all(); } private function uniqueViewers(World $world, ?Carbon $start): int { return (int) $this->baseEventQuery($world, $start) ->where('event_type', self::EVENT_VIEWED) ->distinct('visitor_key') ->count('visitor_key'); } private function sourceBreakdown(World $world, ?Carbon $start): array { $impressions = $this->baseEventQuery($world, $start) ->where('event_type', self::EVENT_SOURCE_IMPRESSION) ->select('source_surface', DB::raw('COUNT(*) as impressions')) ->groupBy('source_surface') ->get() ->keyBy(fn (WorldAnalyticsEvent $event): string => (string) ($event->source_surface ?: self::SOURCE_UNKNOWN)); $views = $this->baseEventQuery($world, $start) ->where('event_type', self::EVENT_VIEWED) ->select('source_surface', DB::raw('COUNT(*) as views'), DB::raw('COUNT(DISTINCT visitor_key) as unique_visitors')) ->groupBy('source_surface') ->get() ->keyBy(fn (WorldAnalyticsEvent $event): string => (string) ($event->source_surface ?: self::SOURCE_UNKNOWN)); $clicks = $this->baseEventQuery($world, $start) ->where('event_type', self::EVENT_SOURCE_CLICKED) ->select('source_surface', DB::raw('COUNT(*) as clicks')) ->groupBy('source_surface') ->get() ->keyBy(fn (WorldAnalyticsEvent $event): string => (string) ($event->source_surface ?: self::SOURCE_UNKNOWN)); return collect($this->allowedSourceSurfaces()) ->map(function (string $surface) use ($impressions, $views, $clicks): array { $impressionRow = $impressions->get($surface); $viewRow = $views->get($surface); $clickRow = $clicks->get($surface); $impressionCount = (int) ($impressionRow?->impressions ?? 0); $clickCount = (int) ($clickRow?->clicks ?? 0); $viewCount = (int) ($viewRow?->views ?? 0); return [ 'source_surface' => $surface, 'label' => $this->sourceSurfaceLabel($surface), 'impressions' => $impressionCount, 'views' => $viewCount, 'unique_visitors' => (int) ($viewRow?->unique_visitors ?? 0), 'clicks' => $clickCount, 'clickthrough_rate' => $impressionCount > 0 ? round($clickCount / $impressionCount, 4) : 0.0, 'visit_rate' => $impressionCount > 0 ? round($viewCount / $impressionCount, 4) : 0.0, ]; }) ->filter(fn (array $row): bool => $row['impressions'] > 0 || $row['views'] > 0 || $row['clicks'] > 0) ->sortByDesc('views') ->values() ->all(); } private function sectionPerformance(World $world, ?Carbon $start): array { return $this->baseEventQuery($world, $start) ->whereNotNull('section_key') ->whereIn('event_type', [ self::EVENT_SECTION_CLICKED, self::EVENT_CTA_CLICKED, self::EVENT_ENTITY_CLICKED, self::EVENT_CHALLENGE_CTA_CLICKED, ]) ->select('section_key', DB::raw('COUNT(*) as clicks')) ->groupBy('section_key') ->orderByDesc('clicks') ->get() ->map(fn (WorldAnalyticsEvent $event): array => [ 'section_key' => (string) $event->section_key, 'label' => Str::headline((string) $event->section_key), 'clicks' => (int) $event->clicks, ]) ->all(); } private function entityPerformance(World $world, ?Carbon $start): array { return $this->baseEventQuery($world, $start) ->where('event_type', self::EVENT_ENTITY_CLICKED) ->whereNotNull('entity_type') ->whereNotNull('entity_id') ->select('section_key', 'entity_type', 'entity_id', 'entity_title', DB::raw('COUNT(*) as clicks')) ->groupBy('section_key', 'entity_type', 'entity_id', 'entity_title') ->orderByDesc('clicks') ->limit(8) ->get() ->map(fn (WorldAnalyticsEvent $event): array => [ 'section_key' => (string) ($event->section_key ?? ''), 'entity_type' => (string) ($event->entity_type ?? ''), 'entity_id' => (int) ($event->entity_id ?? 0), 'entity_title' => (string) ($event->entity_title ?: Str::headline((string) $event->entity_type)), 'clicks' => (int) $event->clicks, ]) ->all(); } private function ctaPerformance(World $world, ?Carbon $start): array { return $this->baseEventQuery($world, $start) ->whereIn('event_type', [self::EVENT_CTA_CLICKED, self::EVENT_CHALLENGE_CTA_CLICKED]) ->select('event_type', 'section_key', 'cta_key', DB::raw('COUNT(*) as clicks')) ->groupBy('event_type', 'section_key', 'cta_key') ->orderByDesc('clicks') ->get() ->map(fn (WorldAnalyticsEvent $event): array => [ 'event_type' => (string) $event->event_type, 'section_key' => (string) ($event->section_key ?? ''), 'cta_key' => (string) ($event->cta_key ?? ''), 'label' => $this->ctaLabel((string) ($event->cta_key ?? '')), 'clicks' => (int) $event->clicks, ]) ->all(); } private function currentSubmissionCounts(World $world): array { $counts = WorldSubmission::query() ->where('world_id', (int) $world->id) ->select('status', DB::raw('COUNT(*) as total')) ->groupBy('status') ->pluck('total', 'status'); $featured = (int) WorldSubmission::query() ->where('world_id', (int) $world->id) ->where('status', WorldSubmission::STATUS_LIVE) ->where('is_featured', true) ->count(); return [ 'pending' => (int) ($counts[WorldSubmission::STATUS_PENDING] ?? 0), 'live' => (int) ($counts[WorldSubmission::STATUS_LIVE] ?? 0), 'removed' => (int) ($counts[WorldSubmission::STATUS_REMOVED] ?? 0), 'blocked' => (int) ($counts[WorldSubmission::STATUS_BLOCKED] ?? 0), 'featured' => $featured, ]; } private function submissionActivityCounts(World $world, ?Carbon $start, array $eventCounts): array { $createdQuery = WorldSubmission::query()->where('world_id', (int) $world->id); if ($start) { $createdQuery->where('created_at', '>=', $start); } $submitted = (int) $createdQuery->count(); $approved = (int) ($eventCounts[self::EVENT_SUBMISSION_APPROVED] ?? 0); $removed = (int) ($eventCounts[self::EVENT_SUBMISSION_REMOVED] ?? 0); $blocked = (int) ($eventCounts[self::EVENT_SUBMISSION_BLOCKED] ?? 0); $featured = (int) ($eventCounts[self::EVENT_SUBMISSION_FEATURED] ?? 0); $views = max(1, (int) ($eventCounts[self::EVENT_VIEWED] ?? 0)); return [ 'submitted' => $submitted, 'approved' => $approved, 'removed_actions' => $removed, 'blocked_actions' => $blocked, 'featured_actions' => $featured, 'approval_rate' => $submitted > 0 ? round($approved / $submitted, 4) : 0.0, 'removal_rate' => $submitted > 0 ? round($removed / $submitted, 4) : 0.0, 'block_rate' => $submitted > 0 ? round($blocked / $submitted, 4) : 0.0, 'view_to_submission_conversion' => round($submitted / $views, 4), ]; } private function rewardCounts(World $world, ?Carbon $start): array { $query = WorldRewardGrant::query()->where('world_id', (int) $world->id); if ($start) { $query->where('granted_at', '>=', $start); } $counts = $query ->select('reward_type', DB::raw('COUNT(*) as total')) ->groupBy('reward_type') ->pluck('total', 'reward_type'); return [ 'total' => (int) collect($counts)->sum(), 'participant' => (int) ($counts[WorldRewardType::Participant->value] ?? 0), 'featured' => (int) ($counts[WorldRewardType::Featured->value] ?? 0), 'finalist' => (int) ($counts[WorldRewardType::Finalist->value] ?? 0), 'winner' => (int) ($counts[WorldRewardType::Winner->value] ?? 0), 'spotlight' => (int) ($counts[WorldRewardType::Spotlight->value] ?? 0), ]; } private function challengeMetrics(World $world, ?Carbon $start, array $eventCounts): array { $query = $this->baseEventQuery($world, $start)->whereNotNull('challenge_id'); $recapClicks = (clone $query) ->where('event_type', self::EVENT_CHALLENGE_CTA_CLICKED) ->whereIn('cta_key', ['challenge_story', 'challenge_recap']) ->count(); $entryClicks = (clone $query) ->where('event_type', self::EVENT_ENTITY_CLICKED) ->where('section_key', 'challenge_entries') ->count(); $winnerClicks = (clone $query) ->where('event_type', self::EVENT_ENTITY_CLICKED) ->where('section_key', 'challenge_winners') ->count(); $finalistClicks = (clone $query) ->where('event_type', self::EVENT_ENTITY_CLICKED) ->where('section_key', 'challenge_finalists') ->count(); $challengeCtaClicks = (int) ($eventCounts[self::EVENT_CHALLENGE_CTA_CLICKED] ?? 0); $submissionStarts = (int) ($eventCounts[self::EVENT_SUBMISSION_STARTED] ?? 0); $submissionsCreated = (int) ($eventCounts[self::EVENT_SUBMISSION_CREATED] ?? 0); $totalClicks = $challengeCtaClicks + (int) $recapClicks + (int) $entryClicks + (int) $winnerClicks + (int) $finalistClicks; return [ 'linked_challenge_id' => $world->linked_challenge_id ? (int) $world->linked_challenge_id : null, 'challenge_cta_clicks' => $challengeCtaClicks, 'recap_clicks' => (int) $recapClicks, 'entry_clicks' => (int) $entryClicks, 'winner_clicks' => (int) $winnerClicks, 'finalist_clicks' => (int) $finalistClicks, 'submission_starts' => $submissionStarts, 'submissions_created' => $submissionsCreated, 'click_to_submission_start_conversion' => $totalClicks > 0 ? round($submissionStarts / $totalClicks, 4) : 0.0, 'click_to_submission_conversion' => $totalClicks > 0 ? round($submissionsCreated / $totalClicks, 4) : 0.0, 'total_clicks' => $totalClicks, ]; } private function portfolioRangePayload(?Carbon $start): array { $worlds = World::query() ->select(['id', 'title', 'slug', 'recurrence_key', 'edition_year']) ->get() ->keyBy('id'); $viewRows = WorldAnalyticsEvent::query() ->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start)) ->where('event_type', self::EVENT_VIEWED) ->select('world_id', DB::raw('COUNT(*) as views'), DB::raw('COUNT(DISTINCT visitor_key) as unique_visitors')) ->groupBy('world_id') ->get() ->keyBy('world_id'); $impressionRows = WorldAnalyticsEvent::query() ->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start)) ->where('event_type', self::EVENT_SOURCE_IMPRESSION) ->select('world_id', DB::raw('COUNT(*) as impressions')) ->groupBy('world_id') ->get() ->keyBy('world_id'); $sourceClickRows = WorldAnalyticsEvent::query() ->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start)) ->where('event_type', self::EVENT_SOURCE_CLICKED) ->select('world_id', DB::raw('COUNT(*) as source_clicks')) ->groupBy('world_id') ->get() ->keyBy('world_id'); $submissionRows = WorldSubmission::query() ->when($start, fn (Builder $builder): Builder => $builder->where('created_at', '>=', $start)) ->select('world_id', DB::raw('COUNT(*) as submissions')) ->groupBy('world_id') ->get() ->keyBy('world_id'); $rewardRows = WorldRewardGrant::query() ->when($start, fn (Builder $builder): Builder => $builder->where('granted_at', '>=', $start)) ->select('world_id', DB::raw('COUNT(*) as reward_grants')) ->groupBy('world_id') ->get() ->keyBy('world_id'); $trackedWorldIds = collect() ->merge($viewRows->keys()) ->merge($impressionRows->keys()) ->merge($sourceClickRows->keys()) ->merge($submissionRows->keys()) ->merge($rewardRows->keys()) ->map(fn ($id): int => (int) $id) ->unique() ->values(); $rows = $trackedWorldIds ->map(function (int $worldId) use ($worlds, $viewRows, $impressionRows, $sourceClickRows, $submissionRows, $rewardRows): ?array { /** @var World|null $world */ $world = $worlds->get($worldId); if (! $world) { return null; } $views = (int) ($viewRows->get($worldId)?->views ?? 0); $uniqueVisitors = (int) ($viewRows->get($worldId)?->unique_visitors ?? 0); $impressions = (int) ($impressionRows->get($worldId)?->impressions ?? 0); $sourceClicks = (int) ($sourceClickRows->get($worldId)?->source_clicks ?? 0); $submissions = (int) ($submissionRows->get($worldId)?->submissions ?? 0); $rewardGrants = (int) ($rewardRows->get($worldId)?->reward_grants ?? 0); return [ 'world_id' => (int) $world->id, 'title' => (string) $world->title, 'slug' => (string) $world->slug, 'edition_year' => $world->edition_year ? (int) $world->edition_year : null, 'recurrence_key' => $this->nullableString($world->recurrence_key), 'edit_url' => route('studio.worlds.edit', ['world' => $world->id]), 'public_url' => $world->publicUrl(), 'views' => $views, 'unique_visitors' => $uniqueVisitors, 'impressions' => $impressions, 'source_clicks' => $sourceClicks, 'submissions' => $submissions, 'reward_grants' => $rewardGrants, 'view_to_submission_conversion' => $views > 0 ? round($submissions / $views, 4) : 0.0, 'promotion_clickthrough_rate' => $impressions > 0 ? round($sourceClicks / $impressions, 4) : 0.0, ]; }) ->filter() ->values(); return [ 'summary' => [ 'tracked_worlds' => $rows->count(), 'views' => (int) $rows->sum('views'), 'unique_visitors' => (int) $rows->sum('unique_visitors'), 'promotion_impressions' => (int) $rows->sum('impressions'), 'submissions' => (int) $rows->sum('submissions'), 'reward_grants' => (int) $rows->sum('reward_grants'), ], 'leaderboards' => [ 'views' => $rows->sortByDesc('views')->take(5)->values()->all(), 'unique_visitors' => $rows->sortByDesc('unique_visitors')->take(5)->values()->all(), 'submissions' => $rows->sortByDesc('submissions')->take(5)->values()->all(), 'reward_grants' => $rows->sortByDesc('reward_grants')->take(5)->values()->all(), 'conversion' => $rows ->filter(fn (array $row): bool => $row['views'] > 0 || $row['submissions'] > 0) ->sortByDesc('view_to_submission_conversion') ->take(5) ->values() ->all(), ], ]; } private function trafficTrend(World $world, ?Carbon $start): array { $events = $this->baseEventQuery($world, $start) ->where('event_type', self::EVENT_VIEWED) ->orderBy('occurred_at') ->pluck('occurred_at') ->map(fn ($timestamp): Carbon => Carbon::parse($timestamp)); if ($events->isEmpty()) { return []; } $bucketByMonth = $start === null && $events->first()?->diffInDays($events->last()) > 90; return $events ->groupBy(fn (Carbon $date): string => $bucketByMonth ? $date->format('Y-m') : $date->toDateString()) ->map(fn (Collection $items, string $label): array => [ 'label' => $label, 'views' => $items->count(), ]) ->values() ->all(); } private function editionComparisonPayload(World $world): ?array { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); if ($recurrenceKey === '') { return null; } $editions = World::query() ->where('recurrence_key', $recurrenceKey) ->orderByDesc('edition_year') ->orderByDesc('starts_at') ->get(); if ($editions->count() < 2) { return null; } $worldIds = $editions->pluck('id')->map(fn ($id): int => (int) $id)->all(); $viewCounts = WorldAnalyticsEvent::query() ->whereIn('world_id', $worldIds) ->where('event_type', self::EVENT_VIEWED) ->select('world_id', DB::raw('COUNT(*) as total_views'), DB::raw('COUNT(DISTINCT visitor_key) as unique_visitors')) ->groupBy('world_id') ->get() ->keyBy('world_id'); $submissionCounts = WorldSubmission::query() ->whereIn('world_id', $worldIds) ->select( 'world_id', DB::raw('COUNT(*) as submitted_total'), DB::raw("SUM(CASE WHEN status = 'live' THEN 1 ELSE 0 END) as live_total"), DB::raw("SUM(CASE WHEN is_featured = 1 AND status = 'live' THEN 1 ELSE 0 END) as featured_total") ) ->groupBy('world_id') ->get() ->keyBy('world_id'); $challengeClicks = WorldAnalyticsEvent::query() ->whereIn('world_id', $worldIds) ->whereIn('event_type', [self::EVENT_CHALLENGE_CTA_CLICKED, self::EVENT_ENTITY_CLICKED]) ->where(function (Builder $builder): void { $builder->whereNotNull('challenge_id') ->orWhereIn('section_key', ['challenge_entries', 'challenge_winners', 'challenge_finalists', 'challenge']); }) ->select('world_id', DB::raw('COUNT(*) as total_challenge_clicks')) ->groupBy('world_id') ->get() ->keyBy('world_id'); $rewardCounts = WorldRewardGrant::query() ->whereIn('world_id', $worldIds) ->select('world_id', DB::raw('COUNT(*) as total_rewards')) ->groupBy('world_id') ->get() ->keyBy('world_id'); return [ 'recurrence_key' => $recurrenceKey, 'label' => trim((string) ($world->title ?: Str::headline($recurrenceKey))), 'editions' => $editions->map(function (World $edition) use ($world, $viewCounts, $submissionCounts, $challengeClicks, $rewardCounts): array { $views = $viewCounts->get((int) $edition->id); $submissions = $submissionCounts->get((int) $edition->id); $challenge = $challengeClicks->get((int) $edition->id); $rewards = $rewardCounts->get((int) $edition->id); return [ 'world_id' => (int) $edition->id, 'title' => (string) $edition->title, 'edition_year' => $edition->edition_year ? (int) $edition->edition_year : null, 'public_url' => $edition->publicUrl(), 'is_current_world' => (int) $edition->id === (int) $world->id, 'metrics' => [ 'views' => (int) ($views?->total_views ?? 0), 'unique_visitors' => (int) ($views?->unique_visitors ?? 0), 'submissions' => (int) ($submissions?->submitted_total ?? 0), 'live_participations' => (int) ($submissions?->live_total ?? 0), 'featured_participations' => (int) ($submissions?->featured_total ?? 0), 'challenge_clicks' => (int) ($challenge?->total_challenge_clicks ?? 0), 'reward_grants' => (int) ($rewards?->total_rewards ?? 0), ], ]; })->all(), ]; } private function baseEventQuery(World $world, ?Carbon $start): Builder { return WorldAnalyticsEvent::query() ->where('world_id', (int) $world->id) ->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start)); } private function resolveVisitorKey(Request $request, ?User $user, string $visitorToken): string { if ($user) { return hash('sha256', 'user:' . $user->id); } $token = trim($visitorToken); if ($token !== '') { return hash('sha256', 'visitor:' . $token); } $sessionId = $request->session()->getId(); if ($sessionId !== '') { return hash('sha256', 'session:' . $sessionId); } return hash('sha256', 'fallback:' . ($request->ip() ?? 'unknown') . '|' . Str::limit((string) $request->userAgent(), 120, '')); } private function sanitizeMeta(array $meta): ?array { $sanitized = collect($meta) ->map(function ($value) { if (is_scalar($value) || $value === null) { return $value; } if (is_array($value)) { return collect($value) ->filter(fn ($entry) => is_scalar($entry) || $entry === null) ->map(fn ($entry) => is_string($entry) ? Str::limit($entry, 200, '') : $entry) ->values() ->all(); } return null; }) ->filter(fn ($value) => $value !== null && $value !== '') ->all(); return $sanitized === [] ? null : $sanitized; } private function ctaLabel(string $ctaKey): string { return match ($ctaKey) { 'main_world_cta' => 'Main world CTA', 'badge_cta' => 'Badge CTA', 'challenge_primary' => 'Challenge primary CTA', 'challenge_story' => 'Challenge story CTA', 'challenge_recap' => 'Challenge recap CTA', 'challenge_direct' => 'Direct challenge CTA', 'linked_group' => 'Linked group CTA', 'family_route' => 'Family route CTA', 'edition_archive' => 'Edition archive CTA', 'supporting_item' => 'Supporting item CTA', default => Str::headline(str_replace('_', ' ', $ctaKey ?: 'cta')), }; } private function nullableString(mixed $value): ?string { $text = trim((string) ($value ?? '')); return $text === '' ? null : Str::limit($text, 180, ''); } }