Files
SkinbaseNova/app/Services/EarlyGrowth/GridFiller.php

130 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* GridFiller
*
* Ensures that browse / discover grids never appear half-empty.
* When real results fall below the configured minimum, it backfills
* with real trending artworks from the general pool.
*
* Rules (per spec):
* - Fill only the visible first page — never mix page-number scopes.
* - Filler is always real content (no fake items).
* - The original total is not reduced (pagination links stay stable).
* - Content is not labelled as "filler" in the UI — it is just valid content.
*/
final class GridFiller
{
/**
* Ensure a LengthAwarePaginator contains at least $minimum items on page 1.
* Returns the original paginator unchanged when:
* - EGS is disabled
* - Page is > 1
* - Real result count already meets the minimum
*/
public function fill(
LengthAwarePaginator $results,
int $minimum = 0,
int $page = 1,
): LengthAwarePaginator {
if (! EarlyGrowth::gridFillerEnabled() || $page > 1) {
return $results;
}
$minimum = $minimum > 0
? $minimum
: (int) config('early_growth.grid_min_results', 12);
$items = $results->getCollection();
$count = $items->count();
if ($count >= $minimum) {
return $results;
}
$needed = $minimum - $count;
$exclude = $items->pluck('id')->all();
$filler = $this->fetchTrendingFiller($needed + 6, $exclude)->take($needed);
$merged = $items
->concat($filler)
->unique('id')
->values();
return new LengthAwarePaginator(
$merged->all(),
max((int) $results->total(), $merged->count()), // never shrink reported total
$results->perPage(),
$page,
[
'path' => $results->path(),
'pageName' => $results->getPageName(),
]
);
}
/**
* Fill a plain Collection (for non-paginated grids like homepage sections).
*/
public function fillCollection(Collection $items, int $minimum = 0): Collection
{
if (! EarlyGrowth::gridFillerEnabled()) {
return $items;
}
$minimum = $minimum > 0
? $minimum
: (int) config('early_growth.grid_min_results', 12);
if ($items->count() >= $minimum) {
return $items;
}
$needed = $minimum - $items->count();
$exclude = $items->pluck('id')->all();
$filler = $this->fetchTrendingFiller($needed + 6, $exclude)->take($needed);
return $items->concat($filler)->unique('id')->values();
}
// ─── Private ─────────────────────────────────────────────────────────────
/**
* Pull high-ranking artworks as grid filler.
* Cache key includes an exclude-hash so different grids get distinct content.
*/
private function fetchTrendingFiller(int $limit, array $excludeIds): Collection
{
$ttl = (int) config('early_growth.cache_ttl.feed_blend', 300);
$excludeHash = md5(implode(',', array_slice(array_unique($excludeIds), 0, 50)));
$cacheKey = "egs.grid_filler.{$excludeHash}.{$limit}";
return Cache::remember($cacheKey, $ttl, function () use ($limit, $excludeIds): Collection {
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
])
->leftJoin('artwork_stats as _gf_stats', '_gf_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->when(! empty($excludeIds), fn ($q) => $q->whereNotIn('artworks.id', $excludeIds))
->orderByDesc('_gf_stats.ranking_score')
->limit($limit)
->get()
->values();
});
}
}