# 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.