$this->emptyOverview(), 'daily_feedback' => [], 'trend_summary' => $this->emptyTrendSummary(), 'by_surface' => [], 'by_algo_surface' => [], 'top_artworks' => [], ]; } $dailyFeedback = $this->dailyFeedback($from, $to); $trendSummary = $this->trendSummary($dailyFeedback); $surfaceTrendMap = $this->surfaceTrendMap($from, $to); $bySurface = $this->attachSurfaceTrendMap($this->bySurface($from, $to), $surfaceTrendMap); $algoSurfaceTrendMap = $this->algoSurfaceTrendMap($from, $to); $byAlgoSurface = $this->attachAlgoSurfaceTrendMap($this->byAlgoSurface($from, $to), $algoSurfaceTrendMap); return [ 'overview' => $this->overview($from, $to), 'daily_feedback' => $dailyFeedback, 'trend_summary' => $trendSummary, 'by_surface' => $bySurface, 'by_algo_surface' => $byAlgoSurface, 'top_artworks' => $this->topArtworks($from, $to, $limit), 'latest_aggregated_date' => $this->latestAggregatedDate(), ]; } private function overview(string $from, string $to): array { $row = DB::table('user_discovery_events') ->selectRaw('COUNT(*) AS total_events') ->selectRaw('COUNT(DISTINCT user_id) AS unique_users') ->selectRaw('COUNT(DISTINCT artwork_id) AS unique_artworks') ->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views") ->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks") ->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites") ->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads") ->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks") ->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags") ->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks") ->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags") ->whereBetween('event_date', [$from, $to]) ->first(); $views = (int) ($row->views ?? 0); $clicks = (int) ($row->clicks ?? 0); $favorites = (int) ($row->favorites ?? 0); $downloads = (int) ($row->downloads ?? 0); $hiddenArtworks = (int) ($row->hidden_artworks ?? 0); $dislikedTags = (int) ($row->disliked_tags ?? 0); $undoHiddenArtworks = (int) ($row->undo_hidden_artworks ?? 0); $undoDislikedTags = (int) ($row->undo_disliked_tags ?? 0); $feedbackActions = $favorites + $downloads; $negativeFeedbackActions = $hiddenArtworks + $dislikedTags; $undoActions = $undoHiddenArtworks + $undoDislikedTags; return [ 'total_events' => (int) ($row->total_events ?? 0), 'unique_users' => (int) ($row->unique_users ?? 0), 'unique_artworks' => (int) ($row->unique_artworks ?? 0), 'views' => $views, 'clicks' => $clicks, 'favorites' => $favorites, 'downloads' => $downloads, 'feedback_actions' => $feedbackActions, 'hidden_artworks' => $hiddenArtworks, 'disliked_tags' => $dislikedTags, 'negative_feedback_actions' => $negativeFeedbackActions, 'undo_hidden_artworks' => $undoHiddenArtworks, 'undo_disliked_tags' => $undoDislikedTags, 'undo_actions' => $undoActions, 'ctr' => round($views > 0 ? $clicks / $views : 0.0, 6), 'favorite_rate_per_click' => round($clicks > 0 ? $favorites / $clicks : 0.0, 6), 'download_rate_per_click' => round($clicks > 0 ? $downloads / $clicks : 0.0, 6), 'feedback_rate_per_click' => round($clicks > 0 ? $feedbackActions / $clicks : 0.0, 6), 'negative_feedback_rate_per_click' => round($clicks > 0 ? $negativeFeedbackActions / $clicks : 0.0, 6), 'undo_rate_per_negative_feedback' => round($negativeFeedbackActions > 0 ? $undoActions / $negativeFeedbackActions : 0.0, 6), ]; } private function bySurface(string $from, string $to): array { if (Schema::hasTable('discovery_feedback_daily_metrics')) { return DB::table('discovery_feedback_daily_metrics') ->selectRaw('surface') ->selectRaw('SUM(views) AS views') ->selectRaw('SUM(clicks) AS clicks') ->selectRaw('SUM(favorites) AS favorites') ->selectRaw('SUM(downloads) AS downloads') ->selectRaw('SUM(feedback_actions) AS feedback_actions') ->selectRaw('SUM(hidden_artworks) AS hidden_artworks') ->selectRaw('SUM(disliked_tags) AS disliked_tags') ->selectRaw('SUM(negative_feedback_actions) AS negative_feedback_actions') ->selectRaw('SUM(undo_hidden_artworks) AS undo_hidden_artworks') ->selectRaw('SUM(undo_disliked_tags) AS undo_disliked_tags') ->selectRaw('SUM(undo_actions) AS undo_actions') ->selectRaw('SUM(unique_users) AS unique_users') ->selectRaw('SUM(unique_artworks) AS unique_artworks') ->whereBetween('metric_date', [$from, $to]) ->groupBy('surface') ->orderByDesc('clicks') ->orderByDesc('favorites') ->get() ->map(fn ($row): array => $this->formatEventSummaryRow($row, ['surface' => (string) ($row->surface ?? 'unknown')])) ->all(); } $surfaceExpression = $this->surfaceExpression(); return DB::table('user_discovery_events') ->selectRaw($surfaceExpression . ' AS surface') ->selectRaw('COUNT(*) AS total_events') ->selectRaw('COUNT(DISTINCT user_id) AS unique_users') ->selectRaw('COUNT(DISTINCT artwork_id) AS unique_artworks') ->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views") ->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks") ->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites") ->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads") ->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks") ->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags") ->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks") ->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags") ->whereBetween('event_date', [$from, $to]) ->groupBy(DB::raw($surfaceExpression)) ->orderByDesc('clicks') ->orderByDesc('favorites') ->get() ->map(fn ($row): array => $this->formatEventSummaryRow($row, ['surface' => (string) ($row->surface ?? 'unknown')])) ->all(); } private function byAlgoSurface(string $from, string $to): array { if (Schema::hasTable('discovery_feedback_daily_metrics')) { return DB::table('discovery_feedback_daily_metrics') ->selectRaw('algo_version') ->selectRaw('surface') ->selectRaw('SUM(views) AS views') ->selectRaw('SUM(clicks) AS clicks') ->selectRaw('SUM(favorites) AS favorites') ->selectRaw('SUM(downloads) AS downloads') ->selectRaw('SUM(feedback_actions) AS feedback_actions') ->selectRaw('SUM(hidden_artworks) AS hidden_artworks') ->selectRaw('SUM(disliked_tags) AS disliked_tags') ->selectRaw('SUM(negative_feedback_actions) AS negative_feedback_actions') ->selectRaw('SUM(undo_hidden_artworks) AS undo_hidden_artworks') ->selectRaw('SUM(undo_disliked_tags) AS undo_disliked_tags') ->selectRaw('SUM(undo_actions) AS undo_actions') ->selectRaw('SUM(unique_users) AS unique_users') ->selectRaw('SUM(unique_artworks) AS unique_artworks') ->whereBetween('metric_date', [$from, $to]) ->groupBy('algo_version', 'surface') ->orderBy('algo_version') ->orderByDesc('clicks') ->get() ->map(fn ($row): array => $this->formatEventSummaryRow($row, [ 'algo_version' => (string) ($row->algo_version ?? ''), 'surface' => (string) ($row->surface ?? 'unknown'), ])) ->all(); } $surfaceExpression = $this->surfaceExpression(); return DB::table('user_discovery_events') ->selectRaw('algo_version') ->selectRaw($surfaceExpression . ' AS surface') ->selectRaw('COUNT(*) AS total_events') ->selectRaw('COUNT(DISTINCT user_id) AS unique_users') ->selectRaw('COUNT(DISTINCT artwork_id) AS unique_artworks') ->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views") ->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks") ->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites") ->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads") ->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks") ->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags") ->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks") ->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags") ->whereBetween('event_date', [$from, $to]) ->groupBy('algo_version', DB::raw($surfaceExpression)) ->orderBy('algo_version') ->orderByDesc('clicks') ->get() ->map(fn ($row): array => $this->formatEventSummaryRow($row, [ 'algo_version' => (string) ($row->algo_version ?? ''), 'surface' => (string) ($row->surface ?? 'unknown'), ])) ->all(); } private function topArtworks(string $from, string $to, int $limit): array { $surfaceExpression = $this->surfaceExpression(); return DB::table('user_discovery_events as e') ->leftJoin('artworks as a', 'a.id', '=', 'e.artwork_id') ->selectRaw('e.artwork_id') ->selectRaw('a.title as artwork_title') ->selectRaw('e.algo_version') ->selectRaw($surfaceExpression . ' AS surface') ->selectRaw("SUM(CASE WHEN e.event_type = 'view' THEN 1 ELSE 0 END) AS views") ->selectRaw("SUM(CASE WHEN e.event_type = 'click' THEN 1 ELSE 0 END) AS clicks") ->selectRaw("SUM(CASE WHEN e.event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites") ->selectRaw("SUM(CASE WHEN e.event_type = 'download' THEN 1 ELSE 0 END) AS downloads") ->selectRaw("SUM(CASE WHEN e.event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks") ->selectRaw("SUM(CASE WHEN e.event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags") ->selectRaw("SUM(CASE WHEN e.event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks") ->selectRaw("SUM(CASE WHEN e.event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags") ->whereBetween('e.event_date', [$from, $to]) ->groupBy('e.artwork_id', 'a.title', 'e.algo_version', DB::raw($surfaceExpression)) ->get() ->map(fn ($row): array => $this->formatEventSummaryRow($row, [ 'artwork_id' => (int) ($row->artwork_id ?? 0), 'artwork_title' => (string) ($row->artwork_title ?? ''), 'algo_version' => (string) ($row->algo_version ?? ''), 'surface' => (string) ($row->surface ?? 'unknown'), ])) ->sort(static function (array $left, array $right): int { $favoriteCompare = $right['favorites'] <=> $left['favorites']; if ($favoriteCompare !== 0) { return $favoriteCompare; } $downloadCompare = $right['downloads'] <=> $left['downloads']; if ($downloadCompare !== 0) { return $downloadCompare; } return $right['clicks'] <=> $left['clicks']; }) ->take($limit) ->values() ->all(); } private function dailyFeedback(string $from, string $to): array { if (Schema::hasTable('discovery_feedback_daily_metrics')) { return DB::table('discovery_feedback_daily_metrics') ->selectRaw('metric_date') ->selectRaw('SUM(views) AS views') ->selectRaw('SUM(clicks) AS clicks') ->selectRaw('SUM(favorites) AS favorites') ->selectRaw('SUM(downloads) AS downloads') ->selectRaw('SUM(feedback_actions) AS feedback_actions') ->selectRaw('SUM(hidden_artworks) AS hidden_artworks') ->selectRaw('SUM(disliked_tags) AS disliked_tags') ->selectRaw('SUM(negative_feedback_actions) AS negative_feedback_actions') ->selectRaw('SUM(undo_hidden_artworks) AS undo_hidden_artworks') ->selectRaw('SUM(undo_disliked_tags) AS undo_disliked_tags') ->selectRaw('SUM(undo_actions) AS undo_actions') ->whereBetween('metric_date', [$from, $to]) ->groupBy('metric_date') ->orderBy('metric_date') ->get() ->map(fn ($row): array => [ 'date' => (string) $row->metric_date, 'views' => (int) ($row->views ?? 0), 'clicks' => (int) ($row->clicks ?? 0), 'favorites' => (int) ($row->favorites ?? 0), 'downloads' => (int) ($row->downloads ?? 0), 'feedback_actions' => (int) ($row->feedback_actions ?? 0), 'hidden_artworks' => (int) ($row->hidden_artworks ?? 0), 'disliked_tags' => (int) ($row->disliked_tags ?? 0), 'negative_feedback_actions' => (int) ($row->negative_feedback_actions ?? 0), 'undo_hidden_artworks' => (int) ($row->undo_hidden_artworks ?? 0), 'undo_disliked_tags' => (int) ($row->undo_disliked_tags ?? 0), 'undo_actions' => (int) ($row->undo_actions ?? 0), ]) ->all(); } return DB::table('user_discovery_events') ->selectRaw('event_date') ->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views") ->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks") ->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites") ->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads") ->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks") ->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags") ->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks") ->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags") ->whereBetween('event_date', [$from, $to]) ->groupBy('event_date') ->orderBy('event_date') ->get() ->map(fn ($row): array => [ 'date' => (string) $row->event_date, 'views' => (int) ($row->views ?? 0), 'clicks' => (int) ($row->clicks ?? 0), 'favorites' => (int) ($row->favorites ?? 0), 'downloads' => (int) ($row->downloads ?? 0), 'feedback_actions' => (int) (($row->favorites ?? 0) + ($row->downloads ?? 0)), 'hidden_artworks' => (int) ($row->hidden_artworks ?? 0), 'disliked_tags' => (int) ($row->disliked_tags ?? 0), 'negative_feedback_actions' => (int) (($row->hidden_artworks ?? 0) + ($row->disliked_tags ?? 0)), 'undo_hidden_artworks' => (int) ($row->undo_hidden_artworks ?? 0), 'undo_disliked_tags' => (int) ($row->undo_disliked_tags ?? 0), 'undo_actions' => (int) (($row->undo_hidden_artworks ?? 0) + ($row->undo_disliked_tags ?? 0)), ]) ->all(); } /** * @param array> $rows * @param array> $surfaceTrendMap * @return array> */ private function attachSurfaceTrendMap(array $rows, array $surfaceTrendMap): array { $rows = array_map(function (array $row) use ($surfaceTrendMap): array { $surface = (string) ($row['surface'] ?? 'unknown'); return array_merge($row, [ 'trend' => $surfaceTrendMap[$surface] ?? $this->emptySurfaceTrend(), ]); }, $rows); return $this->sortRowsByTrendRisk($rows); } /** * @param array> $rows * @param array> $algoSurfaceTrendMap * @return array> */ private function attachAlgoSurfaceTrendMap(array $rows, array $algoSurfaceTrendMap): array { $rows = array_map(function (array $row) use ($algoSurfaceTrendMap): array { $algoVersion = (string) ($row['algo_version'] ?? ''); $surface = (string) ($row['surface'] ?? 'unknown'); $key = $this->algoSurfaceTrendKey($algoVersion, $surface); return array_merge($row, [ 'trend' => $algoSurfaceTrendMap[$key] ?? $this->emptySurfaceTrend(), ]); }, $rows); return $this->sortRowsByTrendRisk($rows); } /** * @return array> */ private function surfaceTrendMap(string $from, string $to): array { $rows = $this->dailySurfaceMetrics($from, $to); if ($rows === []) { return []; } $dates = array_values(array_unique(array_map( static fn (array $row): string => (string) $row['date'], $rows, ))); sort($dates); $latestDate = $dates[array_key_last($dates)] ?? null; $previousDate = count($dates) > 1 ? $dates[count($dates) - 2] : null; $grouped = []; foreach ($rows as $row) { $date = (string) ($row['date'] ?? ''); $surface = (string) ($row['surface'] ?? 'unknown'); $grouped[$surface][$date] = $row; } $trendMap = []; foreach ($grouped as $surface => $surfaceRows) { $latest = $latestDate !== null ? ($surfaceRows[$latestDate] ?? null) : null; $previous = $previousDate !== null ? ($surfaceRows[$previousDate] ?? null) : null; $trendMap[$surface] = [ 'latest_day' => $latest, 'previous_day' => $previous, 'overall_status' => $this->overallTrendStatus($latest, $previous), 'deltas' => [ 'feedback_actions' => $this->formatTrendDelta($latest, $previous, 'feedback_actions', 'up'), 'negative_feedback_actions' => $this->formatTrendDelta($latest, $previous, 'negative_feedback_actions', 'down'), 'undo_actions' => $this->formatTrendDelta($latest, $previous, 'undo_actions', 'up'), ], ]; } return $trendMap; } /** * @return array> */ private function algoSurfaceTrendMap(string $from, string $to): array { $rows = $this->dailyAlgoSurfaceMetrics($from, $to); if ($rows === []) { return []; } $dates = array_values(array_unique(array_map( static fn (array $row): string => (string) $row['date'], $rows, ))); sort($dates); $latestDate = $dates[array_key_last($dates)] ?? null; $previousDate = count($dates) > 1 ? $dates[count($dates) - 2] : null; $grouped = []; foreach ($rows as $row) { $date = (string) ($row['date'] ?? ''); $algoVersion = (string) ($row['algo_version'] ?? ''); $surface = (string) ($row['surface'] ?? 'unknown'); $grouped[$this->algoSurfaceTrendKey($algoVersion, $surface)][$date] = $row; } $trendMap = []; foreach ($grouped as $key => $algoSurfaceRows) { $latest = $latestDate !== null ? ($algoSurfaceRows[$latestDate] ?? null) : null; $previous = $previousDate !== null ? ($algoSurfaceRows[$previousDate] ?? null) : null; $trendMap[$key] = [ 'latest_day' => $latest, 'previous_day' => $previous, 'overall_status' => $this->overallTrendStatus($latest, $previous), 'deltas' => [ 'feedback_actions' => $this->formatTrendDelta($latest, $previous, 'feedback_actions', 'up'), 'negative_feedback_actions' => $this->formatTrendDelta($latest, $previous, 'negative_feedback_actions', 'down'), 'undo_actions' => $this->formatTrendDelta($latest, $previous, 'undo_actions', 'up'), ], ]; } return $trendMap; } /** * @return array> */ private function dailySurfaceMetrics(string $from, string $to): array { if (Schema::hasTable('discovery_feedback_daily_metrics')) { return DB::table('discovery_feedback_daily_metrics') ->selectRaw('metric_date') ->selectRaw('surface') ->selectRaw('SUM(feedback_actions) AS feedback_actions') ->selectRaw('SUM(negative_feedback_actions) AS negative_feedback_actions') ->selectRaw('SUM(undo_actions) AS undo_actions') ->whereBetween('metric_date', [$from, $to]) ->groupBy('metric_date', 'surface') ->orderBy('metric_date') ->get() ->map(fn ($row): array => [ 'date' => (string) $row->metric_date, 'surface' => (string) ($row->surface ?? 'unknown'), 'feedback_actions' => (int) ($row->feedback_actions ?? 0), 'negative_feedback_actions' => (int) ($row->negative_feedback_actions ?? 0), 'undo_actions' => (int) ($row->undo_actions ?? 0), ]) ->all(); } $surfaceExpression = $this->surfaceExpression(); return DB::table('user_discovery_events') ->selectRaw('event_date') ->selectRaw($surfaceExpression . ' AS surface') ->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS feedback_actions") ->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS negative_feedback_actions") ->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_actions") ->whereBetween('event_date', [$from, $to]) ->groupBy('event_date', DB::raw($surfaceExpression)) ->orderBy('event_date') ->get() ->map(fn ($row): array => [ 'date' => (string) $row->event_date, 'surface' => (string) ($row->surface ?? 'unknown'), 'feedback_actions' => (int) ($row->feedback_actions ?? 0), 'negative_feedback_actions' => (int) ($row->negative_feedback_actions ?? 0), 'undo_actions' => (int) ($row->undo_actions ?? 0), ]) ->all(); } /** * @return array> */ private function dailyAlgoSurfaceMetrics(string $from, string $to): array { if (Schema::hasTable('discovery_feedback_daily_metrics')) { return DB::table('discovery_feedback_daily_metrics') ->selectRaw('metric_date') ->selectRaw('algo_version') ->selectRaw('surface') ->selectRaw('SUM(feedback_actions) AS feedback_actions') ->selectRaw('SUM(negative_feedback_actions) AS negative_feedback_actions') ->selectRaw('SUM(undo_actions) AS undo_actions') ->whereBetween('metric_date', [$from, $to]) ->groupBy('metric_date', 'algo_version', 'surface') ->orderBy('metric_date') ->get() ->map(fn ($row): array => [ 'date' => (string) $row->metric_date, 'algo_version' => (string) ($row->algo_version ?? ''), 'surface' => (string) ($row->surface ?? 'unknown'), 'feedback_actions' => (int) ($row->feedback_actions ?? 0), 'negative_feedback_actions' => (int) ($row->negative_feedback_actions ?? 0), 'undo_actions' => (int) ($row->undo_actions ?? 0), ]) ->all(); } $surfaceExpression = $this->surfaceExpression(); return DB::table('user_discovery_events') ->selectRaw('event_date') ->selectRaw('algo_version') ->selectRaw($surfaceExpression . ' AS surface') ->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS feedback_actions") ->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS negative_feedback_actions") ->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_actions") ->whereBetween('event_date', [$from, $to]) ->groupBy('event_date', 'algo_version', DB::raw($surfaceExpression)) ->orderBy('event_date') ->get() ->map(fn ($row): array => [ 'date' => (string) $row->event_date, 'algo_version' => (string) ($row->algo_version ?? ''), 'surface' => (string) ($row->surface ?? 'unknown'), 'feedback_actions' => (int) ($row->feedback_actions ?? 0), 'negative_feedback_actions' => (int) ($row->negative_feedback_actions ?? 0), 'undo_actions' => (int) ($row->undo_actions ?? 0), ]) ->all(); } private function algoSurfaceTrendKey(string $algoVersion, string $surface): string { return $algoVersion . '|' . $surface; } /** * @param array> $rows * @return array> */ private function sortRowsByTrendRisk(array $rows): array { usort($rows, function (array $left, array $right): int { $leftLevel = (string) ($left['trend']['overall_status']['level'] ?? 'neutral'); $rightLevel = (string) ($right['trend']['overall_status']['level'] ?? 'neutral'); $levelCompare = $this->trendLevelRank($leftLevel) <=> $this->trendLevelRank($rightLevel); if ($levelCompare !== 0) { return $levelCompare; } $leftScore = (int) ($left['trend']['overall_status']['score'] ?? 0); $rightScore = (int) ($right['trend']['overall_status']['score'] ?? 0); $scoreCompare = $leftScore <=> $rightScore; if ($scoreCompare !== 0) { return $scoreCompare; } $clickCompare = ((int) ($right['clicks'] ?? 0)) <=> ((int) ($left['clicks'] ?? 0)); if ($clickCompare !== 0) { return $clickCompare; } return ((int) ($right['feedback_actions'] ?? 0)) <=> ((int) ($left['feedback_actions'] ?? 0)); }); return $rows; } private function trendLevelRank(string $level): int { return match ($level) { 'risk' => 0, 'watch' => 1, 'healthy' => 2, default => 3, }; } /** * @param object $row * @param array $base * @return array */ private function formatEventSummaryRow(object $row, array $base): array { $views = (int) ($row->views ?? 0); $clicks = (int) ($row->clicks ?? 0); $favorites = (int) ($row->favorites ?? 0); $downloads = (int) ($row->downloads ?? 0); $hiddenArtworks = (int) ($row->hidden_artworks ?? 0); $dislikedTags = (int) ($row->disliked_tags ?? 0); $undoHiddenArtworks = (int) ($row->undo_hidden_artworks ?? 0); $undoDislikedTags = (int) ($row->undo_disliked_tags ?? 0); $feedbackActions = $favorites + $downloads; $negativeFeedbackActions = (int) ($row->negative_feedback_actions ?? ($hiddenArtworks + $dislikedTags)); $undoActions = (int) ($row->undo_actions ?? ($undoHiddenArtworks + $undoDislikedTags)); return array_merge($base, [ 'total_events' => (int) ($row->total_events ?? ($views + $clicks + $favorites + $downloads + $hiddenArtworks + $dislikedTags + $undoHiddenArtworks + $undoDislikedTags)), 'unique_users' => (int) ($row->unique_users ?? 0), 'unique_artworks' => (int) ($row->unique_artworks ?? 0), 'views' => $views, 'clicks' => $clicks, 'favorites' => $favorites, 'downloads' => $downloads, 'feedback_actions' => $feedbackActions, 'hidden_artworks' => $hiddenArtworks, 'disliked_tags' => $dislikedTags, 'negative_feedback_actions' => $negativeFeedbackActions, 'undo_hidden_artworks' => $undoHiddenArtworks, 'undo_disliked_tags' => $undoDislikedTags, 'undo_actions' => $undoActions, 'ctr' => round($views > 0 ? $clicks / $views : 0.0, 6), 'favorite_rate_per_click' => round($clicks > 0 ? $favorites / $clicks : 0.0, 6), 'download_rate_per_click' => round($clicks > 0 ? $downloads / $clicks : 0.0, 6), 'feedback_rate_per_click' => round($clicks > 0 ? $feedbackActions / $clicks : 0.0, 6), 'negative_feedback_rate_per_click' => round($clicks > 0 ? $negativeFeedbackActions / $clicks : 0.0, 6), 'undo_rate_per_negative_feedback' => round($negativeFeedbackActions > 0 ? $undoActions / $negativeFeedbackActions : 0.0, 6), ]); } private function surfaceExpression(): string { if (DB::connection()->getDriverName() === 'sqlite') { return "COALESCE(NULLIF(JSON_EXTRACT(meta, '$.gallery_type'), ''), NULLIF(JSON_EXTRACT(meta, '$.surface'), ''), 'unknown')"; } return "COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.gallery_type')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.surface')), ''), 'unknown')"; } private function emptyOverview(): array { return [ 'total_events' => 0, 'unique_users' => 0, 'unique_artworks' => 0, 'views' => 0, 'clicks' => 0, 'favorites' => 0, 'downloads' => 0, 'feedback_actions' => 0, 'hidden_artworks' => 0, 'disliked_tags' => 0, 'negative_feedback_actions' => 0, 'undo_hidden_artworks' => 0, 'undo_disliked_tags' => 0, 'undo_actions' => 0, 'ctr' => 0.0, 'favorite_rate_per_click' => 0.0, 'download_rate_per_click' => 0.0, 'feedback_rate_per_click' => 0.0, 'negative_feedback_rate_per_click' => 0.0, 'undo_rate_per_negative_feedback' => 0.0, ]; } private function latestAggregatedDate(): ?string { if (! Schema::hasTable('discovery_feedback_daily_metrics')) { return null; } $date = DB::table('discovery_feedback_daily_metrics')->max('metric_date'); return $date ? (string) $date : null; } /** * @param array> $dailyFeedback * @return array */ private function trendSummary(array $dailyFeedback): array { if ($dailyFeedback === []) { return $this->emptyTrendSummary(); } $latest = $dailyFeedback[array_key_last($dailyFeedback)] ?? null; $previous = count($dailyFeedback) > 1 ? $dailyFeedback[count($dailyFeedback) - 2] : null; $recentSeven = array_slice($dailyFeedback, -7); return [ 'latest_day' => $latest, 'previous_day' => $previous, 'rolling_7d_average' => [ 'views' => $this->averageFromRows($recentSeven, 'views'), 'clicks' => $this->averageFromRows($recentSeven, 'clicks'), 'feedback_actions' => $this->averageFromRows($recentSeven, 'feedback_actions'), 'negative_feedback_actions' => $this->averageFromRows($recentSeven, 'negative_feedback_actions'), 'undo_actions' => $this->averageFromRows($recentSeven, 'undo_actions'), ], 'deltas' => [ 'feedback_actions' => $this->formatTrendDelta($latest, $previous, 'feedback_actions', 'up'), 'negative_feedback_actions' => $this->formatTrendDelta($latest, $previous, 'negative_feedback_actions', 'down'), 'undo_actions' => $this->formatTrendDelta($latest, $previous, 'undo_actions', 'up'), ], 'overall_status' => $this->overallTrendStatus($latest, $previous), ]; } /** * @param array|null $latest * @param array|null $previous * @return array */ private function overallTrendStatus(?array $latest, ?array $previous): array { if ($previous === null) { return [ 'level' => 'neutral', 'label' => 'No prior day', 'reason' => 'A second day of data is required to judge trend health.', 'score' => 0, ]; } $feedbackDelta = $this->formatTrendDelta($latest, $previous, 'feedback_actions', 'up'); $negativeDelta = $this->formatTrendDelta($latest, $previous, 'negative_feedback_actions', 'down'); $undoDelta = $this->formatTrendDelta($latest, $previous, 'undo_actions', 'up'); $score = 0; $score += $feedbackDelta['status'] === 'improved' ? 2 : ($feedbackDelta['status'] === 'worse' ? -2 : 0); $score += $negativeDelta['status'] === 'improved' ? 2 : ($negativeDelta['status'] === 'worse' ? -2 : 0); $score += $undoDelta['status'] === 'improved' ? 1 : ($undoDelta['status'] === 'worse' ? -1 : 0); if ($score >= 3) { return [ 'level' => 'healthy', 'label' => 'Healthy', 'reason' => 'Positive signals are improving faster than negative feedback.', 'score' => $score, ]; } if ($score <= -2) { return [ 'level' => 'risk', 'label' => 'Risk', 'reason' => 'Negative feedback is worsening or positive engagement is slipping.', 'score' => $score, ]; } return [ 'level' => 'watch', 'label' => 'Watch', 'reason' => 'Signals are mixed and worth monitoring.', 'score' => $score, ]; } /** * @param array> $rows */ private function averageFromRows(array $rows, string $key): float { if ($rows === []) { return 0.0; } $sum = array_sum(array_map(static fn (array $row): int => (int) ($row[$key] ?? 0), $rows)); return round($sum / count($rows), 2); } /** * @param array|null $latest * @param array|null $previous * @return array */ private function formatTrendDelta(?array $latest, ?array $previous, string $key, string $goodDirection): array { $latestValue = (int) ($latest[$key] ?? 0); if ($previous === null) { return [ 'value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day', ]; } $delta = $latestValue - (int) ($previous[$key] ?? 0); if ($delta === 0) { return [ 'value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'Flat', ]; } $improved = $goodDirection === 'down' ? $delta < 0 : $delta > 0; return [ 'value' => $delta, 'direction' => $delta > 0 ? 'up' : 'down', 'status' => $improved ? 'improved' : 'worse', 'label' => sprintf('%s %s%s vs prev day', $improved ? 'Improved' : 'Worse', $delta > 0 ? '+' : '', number_format($delta)), ]; } /** * @return array */ private function emptyTrendSummary(): array { return [ 'latest_day' => null, 'previous_day' => null, 'rolling_7d_average' => [ 'views' => 0.0, 'clicks' => 0.0, 'feedback_actions' => 0.0, 'negative_feedback_actions' => 0.0, 'undo_actions' => 0.0, ], 'deltas' => [ 'feedback_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'], 'negative_feedback_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'], 'undo_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'], ], 'overall_status' => [ 'level' => 'neutral', 'label' => 'No prior day', 'reason' => 'A second day of data is required to judge trend health.', 'score' => 0, ], ]; } /** * @return array */ private function emptySurfaceTrend(): array { return [ 'latest_day' => null, 'previous_day' => null, 'overall_status' => [ 'level' => 'neutral', 'label' => 'No prior day', 'reason' => 'A second day of data is required to judge trend health.', 'score' => 0, ], 'deltas' => [ 'feedback_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'], 'negative_feedback_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'], 'undo_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'], ], ]; } }