# Trending ## Route - URL: `GET /discover/trending` - Controller: `App\Http\Controllers\Web\DiscoverController::trending()` - Service: `App\Services\ArtworkSearchService::discoverTrending()` ## What the page actually reads Primary ranking comes from Meilisearch, not directly from MySQL. The controller flow is: 1. Query Meilisearch through `ArtworkSearchService` 2. If the search-backed query throws or returns no items, fall back to a direct MySQL query against `artworks` + `artwork_stats` 3. Hydrate the returned IDs back into full `Artwork` Eloquent models when needed 4. Render `resources/views/web/discover/index.blade.php` So the page is effectively: - ranking source: Meilisearch - fallback ranking source: MySQL + `artwork_stats` - display hydration source: MySQL ## Search query `discoverTrending()` uses: - filter: `is_public = true AND is_approved = true AND created_at >= cutoff` - sort: - `ranking_score:desc` - `engagement_velocity:desc` - `views:desc` `created_at` here is the search index field, not necessarily the raw DB column semantics. ## Time window The cutoff is not hardcoded to 30 days in all cases. `App\Services\EarlyGrowth\AdaptiveTimeWindow` chooses the effective look-back window: - 7 days when uploads/day is healthy - 30 days in moderate activity - 90 days in low activity That widening only changes which artworks are eligible for the query. It does not rewrite timestamps. ## Ranking formula The page does not sort by `trending_score_7d` anymore. It sorts by `ranking_score`, which is produced by `App\Services\Ranking\ArtworkRankingService`. Current V2 ranking formula: ```text base_score = (views_all * 0.2) + (downloads_all * 1.5) + (favourites_all * 2.5) + (comments_count * 3.0) + (shares_count * 4.0) authority_multiplier = 1 + ((log10(1 + author_followers_count) + (author_favourites_received / 1000)) * 0.05) decay_factor = 1 / (1 + age_hours / 48) velocity_boost = ((views_24h * 1.0) + (favourites_24h * 3.0) + (comments_24h * 4.0) + (shares_24h * 5.0)) * 0.5 ranking_score = (base_score * authority_multiplier * decay_factor) + velocity_boost ``` `engagement_velocity` is the raw `velocity_boost` term stored separately in `artwork_stats`. ## Where the numbers come from The page depends mainly on: - `artwork_stats.ranking_score` - `artwork_stats.engagement_velocity` - `artwork_stats.views` - `artwork_stats.downloads` - `artwork_stats.favorites` - `artwork_stats.comments_count` - `artwork_stats.shares_count` - author-level follower and favourites-received signals Those values are copied into the search document by `Artwork::toSearchableArray()`. ## Active jobs and schedules Relevant active schedules: - `nova:recalculate-rankings --sync-rank-scores` every 30 minutes - `skinbase:flush-redis-stats` every 5 minutes Indirectly relevant: - `artworks:publish-scheduled` every minute - Meilisearch indexing jobs dispatched after score recalculation ## Cache behavior - Cache key: `discover.trending.{windowDays}d.{page}` - TTL: 300 seconds That means a ranking update can be correct in Meilisearch but still hidden by app cache for up to 5 minutes. If Meilisearch sort settings are missing or the index returns no results, the controller falls back to a DB query instead of rendering a 500 or an empty page. ## Notes - The view text still says "most-viewed artworks on Skinbase over the past 7 days," but the current implementation is really a `ranking_score` page with a dynamic eligibility window. - The page is only as fresh as both the index and the app cache. - The page no longer uses `GridFiller`, so it should not inject older out-of-window artworks just to pad page 1.