['trending_score_1h', 3], '24h' => ['trending_score_24h', 7], default => ['trending_score_7d', 30], }; // Use the windowed counters: views_24h/views_7d and downloads_24h/downloads_7d // instead of all-time totals so trending reflects recent activity. [$viewCol, $dlCol] = match ($period) { '1h' => ['views_1h', 'downloads_1h'], '24h' => ['views_24h', 'downloads_24h'], default => ['views_7d', 'downloads_7d'], }; [$favCol, $commentCol, $shareCol] = match ($period) { '1h' => ['favourites_1h', 'comments_1h', 'shares_1h'], '24h' => ['favourites_24h', 'comments_24h', 'shares_24h'], default => ['favorites', 'comments_count', 'shares_count'], }; $weights = (array) config('discovery.v2.trending.velocity_weights', []); $wView = (float) ($weights['views'] ?? self::W_VIEW); $wFavorite = (float) ($weights['favorites'] ?? self::W_FAVORITE); $wComment = (float) ($weights['comments'] ?? self::W_REACTION); $wShare = (float) ($weights['shares'] ?? self::W_DOWNLOAD); $cutoff = now()->subDays($windowDays)->toDateTimeString(); $updated = 0; Artwork::query() ->select('id') ->where('is_public', true) ->where('is_approved', true) ->whereNull('deleted_at') ->whereNotNull('published_at') ->where('published_at', '>=', $cutoff) ->orderBy('id') ->chunkById($chunkSize, function ($artworks) use ($column, $viewCol, $dlCol, $favCol, $commentCol, $shareCol, $wFavorite, $wComment, $wShare, $wView, &$updated): void { $ids = $artworks->pluck('id')->toArray(); $inClause = implode(',', array_fill(0, count($ids), '?')); // One bulk UPDATE per chunk – uses pre-computed windowed counters // for views and downloads (accurate rolling windows, reset nightly/weekly) // rather than all-time totals. All other signals use correlated subqueries. // Column name ($column) is controlled internally, not user-supplied. DB::update( "UPDATE artworks SET {$column} = GREATEST( COALESCE((SELECT {$favCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ? + COALESCE((SELECT {$commentCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ? + COALESCE((SELECT {$shareCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ? + COALESCE((SELECT {$dlCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ? + COALESCE((SELECT {$viewCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ? - (TIMESTAMPDIFF(HOUR, artworks.published_at, NOW()) * ?) , 0), last_trending_calculated_at = NOW() WHERE id IN ({$inClause})", array_merge( [$wFavorite, $wComment, $wShare, self::W_DOWNLOAD, $wView, self::DECAY_RATE], $ids ) ); $updated += count($ids); }); Log::info('TrendingService: recalculation complete', [ 'period' => $period, 'column' => $column, 'updated' => $updated, ]); return $updated; } /** * Dispatch Meilisearch re-index jobs for artworks in the trending window. * Called after recalculate() to keep the search index current. */ public function syncToSearchIndex(string $period = '7d', int $chunkSize = 500): void { $windowDays = match ($period) { '1h' => 3, '24h' => 7, default => 30, }; $cutoff = now()->subDays($windowDays)->toDateTimeString(); Artwork::query() ->select('id') ->where('is_public', true) ->where('is_approved', true) ->whereNull('deleted_at') ->where('published_at', '>=', $cutoff) ->chunkById($chunkSize, function ($artworks): void { foreach ($artworks as $artwork) { \App\Jobs\IndexArtworkJob::dispatch($artwork->id); } }); } }