feat: leaderboard with top (by period) and new tabs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Doar Mihai
2026-10-17 16:39:28 +03:00
co-authored by Claude Sonnet 5.5
parent 25e1dd2413
commit 3f8fa9f22f
7 changed files with 343 additions and 0 deletions
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class LeaderboardController extends Controller
{
private const PER_PAGE = 10;
/**
* Display the leaderboard: top products by period, or the newest products.
*/
public function index(Request $request)
{
$tab = $request->query('tab') === 'new' ? 'new' : 'top';
$period = array_key_exists((string) $request->query('period'), Product::LEADERBOARD_PERIODS)
? $request->query('period')
: 'today';
$query = Product::query()->with('tags');
$products = ($tab === 'new' ? $query->approved()->latest('products.created_at')->latest('products.id') : $query->topFor($period))
->paginate(self::PER_PAGE)
->appends($tab === 'new' ? ['tab' => 'new'] : ['tab' => 'top', 'period' => $period]);
return view('leaderboard.index', [
'products' => $products,
'tab' => $tab,
'period' => $period,
'periods' => Product::LEADERBOARD_PERIODS,
]);
}
}
+36
View File
@@ -284,6 +284,42 @@ class Product extends Model
return $query->withCount('upvotes')->orderBy('upvotes_count', 'desc');
}
/**
* Leaderboard periods: key => label.
*/
public const LEADERBOARD_PERIODS = [
'today' => 'Today',
'week' => 'This week',
'month' => 'This month',
'all' => 'All time',
];
/**
* Approved products ranked by the upvotes they received in the period,
* then by total upvotes, then newest. Products with no upvotes in the
* period are left out. Adds `period_upvotes_count` and `total_upvotes_count`.
*/
public function scopeTopFor(Builder $query, string $period): Builder
{
$since = match ($period) {
'today' => now()->startOfDay(),
'week' => now()->subDays(7),
'month' => now()->subDays(30),
default => null,
};
$inPeriod = fn (Builder $upvotes) => $since ? $upvotes->where('created_at', '>=', $since) : $upvotes;
return $query->approved()
->whereHas('upvotes', $inPeriod)
->withCount(['upvotes as period_upvotes_count' => $inPeriod])
->withCount('upvotes as total_upvotes_count')
->orderByDesc('period_upvotes_count')
->orderByDesc('total_upvotes_count')
->orderByDesc('products.created_at')
->orderByDesc('products.id');
}
/**
* Scope a query to sort products.
*/
+3
View File
@@ -2,11 +2,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Upvote extends Model
{
use HasFactory;
/**
* Indicates if the model should be timestamped.
*
@@ -19,6 +19,10 @@
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium {{ request()->routeIs('products.*') ? 'border-primary-500 text-gray-900 dark:text-white' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-300' }}">
Products
</a>
<a href="{{ route('leaderboard') }}"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium {{ request()->routeIs('leaderboard') ? 'border-primary-500 text-gray-900 dark:text-white' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-200' }}">
Leaderboard
</a>
</div>
<!-- Search Bar (Desktop) -->
@@ -230,6 +234,10 @@
class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('products.index') ? 'border-primary-500 text-primary-700 bg-primary-50' : 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700' }}">
Discover
</a>
<a href="{{ route('leaderboard') }}"
class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('leaderboard') ? 'border-primary-500 text-primary-700 dark:text-primary-200 bg-primary-50 dark:bg-primary-900' : 'border-transparent text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">
Leaderboard
</a>
@auth
@if(auth()->user()->hasRole('admin'))
<a href="{{ route('admin.dashboard') }}"
@@ -0,0 +1,88 @@
@extends('layouts.app')
@php
$title = 'Leaderboard - ' . config('app.name');
$pill = fn (bool $active) => $active
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700';
$tabClass = fn (bool $active) => $active
? 'border-primary-500 text-gray-900 dark:text-white'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-200';
$periodLabel = ['today' => 'today', 'week' => 'this week', 'month' => 'this month'][$period] ?? null;
@endphp
@section('content')
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Leaderboard</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">The most upvoted and the newest products from European makers.</p>
<!-- Tabs -->
<div class="mt-6 flex space-x-8 border-b border-gray-200 dark:border-gray-700">
<a href="{{ route('leaderboard', ['tab' => 'top', 'period' => $period]) }}"
class="inline-flex items-center px-1 pb-3 border-b-2 text-sm font-medium {{ $tabClass($tab === 'top') }}">Top</a>
<a href="{{ route('leaderboard', ['tab' => 'new']) }}"
class="inline-flex items-center px-1 pb-3 border-b-2 text-sm font-medium {{ $tabClass($tab === 'new') }}">New</a>
</div>
<!-- Period pills -->
@if($tab === 'top')
<div class="mt-4 flex flex-wrap gap-2">
@foreach($periods as $key => $label)
<a href="{{ route('leaderboard', ['tab' => 'top', 'period' => $key]) }}"
class="inline-flex items-center px-3 py-1 rounded-full border text-sm font-medium {{ $pill($period === $key) }}">{{ $label }}</a>
@endforeach
</div>
@endif
<!-- List -->
@if($products->isEmpty())
<div class="mt-8 text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
@if($tab === 'new')
<p class="text-gray-600 dark:text-gray-400">No products yet.</p>
@else
<p class="text-gray-900 dark:text-white font-medium">No upvotes yet {{ $periodLabel ?? 'at all' }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try a longer period.</p>
@endif
</div>
@else
<ol class="mt-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
@foreach($products as $product)
<li class="flex items-center gap-4 p-4">
<span data-rank="{{ $products->firstItem() + $loop->index }}"
class="w-8 text-xl font-bold text-gray-400 dark:text-gray-500 text-center">{{ $products->firstItem() + $loop->index }}</span>
<a href="{{ route('products.show', $product) }}" class="flex-shrink-0">
@if($product->thumbnail_url)
<img src="{{ $product->thumbnail_url }}" alt="{{ $product->title }}" class="h-12 w-12 rounded-lg object-cover" loading="lazy">
@else
<div class="h-12 w-12 rounded-lg bg-gradient-to-br from-primary-100 to-primary-200 dark:from-primary-900 dark:to-primary-800 flex items-center justify-center text-primary-700 dark:text-primary-200 font-semibold">
{{ strtoupper(substr($product->title, 0, 1)) }}
</div>
@endif
</a>
<div class="flex-1 min-w-0">
<a href="{{ route('products.show', $product) }}" class="text-base font-semibold text-gray-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400">{{ $product->title }}</a>
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">{{ Str::limit($product->description, 100) }}</p>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
@foreach($product->tags->take(3) as $tag)
<span>#{{ $tag->name }}</span>
@endforeach
<x-product.badges :product="$product" />
</div>
</div>
<div class="flex-shrink-0 text-center">
@include('components.product.upvote-button', ['product' => $product, 'size' => 'md'])
@if($tab === 'top' && $periodLabel)
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ $product->period_upvotes_count }} {{ $periodLabel }}</p>
@endif
</div>
</li>
@endforeach
</ol>
<div class="mt-6">{{ $products->links() }}</div>
@endif
</div>
@endsection
+4
View File
@@ -7,6 +7,7 @@ use App\Http\Controllers\BookmarkController;
use App\Http\Controllers\CategoryManagementController;
use App\Http\Controllers\CommentController;
use App\Http\Controllers\CommentModerationController;
use App\Http\Controllers\LeaderboardController;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\TagController;
@@ -34,6 +35,9 @@ Route::middleware(['auth'])->group(function () {
Route::get('/profile/{user}', [ProfileController::class, 'show'])->name('profile.show');
// Leaderboard
Route::get('/leaderboard', [LeaderboardController::class, 'index'])->name('leaderboard');
// Product Routes
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::get('/products/{product}', [ProductController::class, 'show'])->name('products.show');
+169
View File
@@ -0,0 +1,169 @@
<?php
use App\Models\Product;
use App\Models\Upvote;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
uses(RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-10-17 15:00:00'));
});
afterEach(function () {
Carbon::setTestNow();
});
function lbProduct(string $title, string $state = 'approved', array $attrs = []): Product
{
return Product::factory()->{$state}()->create(['title' => $title] + $attrs);
}
function lbUpvotes(Product $product, int $count, ?Carbon $at = null): void
{
Upvote::factory()->count($count)->create([
'product_id' => $product->id,
'created_at' => $at ?? now(),
]);
}
test('top today ranks by upvotes received today, not by product age', function () {
$old = lbProduct('Old Favourite', attrs: ['created_at' => now()->subYear()]);
$new = lbProduct('Fresh Launch');
lbUpvotes($old, 3);
lbUpvotes($new, 1);
$this->get(route('leaderboard', ['period' => 'today']))
->assertOk()
->assertSeeInOrder(['Old Favourite', 'Fresh Launch']);
});
test('today only counts upvotes since midnight', function () {
$a = lbProduct('Upvoted Yesterday');
$b = lbProduct('Upvoted Today');
lbUpvotes($a, 5, now()->startOfDay()->subMinute());
lbUpvotes($b, 1, now()->startOfDay());
$this->get(route('leaderboard', ['period' => 'today']))
->assertSee('Upvoted Today')
->assertDontSee('Upvoted Yesterday');
});
test('week counts the last 7 days and month the last 30', function () {
$sixDays = lbProduct('Six Days Ago');
$eightDays = lbProduct('Eight Days Ago');
$twentyNine = lbProduct('Twenty Nine Days Ago');
$thirtyOne = lbProduct('Thirty One Days Ago');
lbUpvotes($sixDays, 1, now()->subDays(6));
lbUpvotes($eightDays, 1, now()->subDays(8));
lbUpvotes($twentyNine, 1, now()->subDays(29));
lbUpvotes($thirtyOne, 1, now()->subDays(31));
$this->get(route('leaderboard', ['period' => 'week']))
->assertSee('Six Days Ago')
->assertDontSee('Eight Days Ago')
->assertDontSee('Twenty Nine Days Ago');
$this->get(route('leaderboard', ['period' => 'month']))
->assertSee('Six Days Ago')
->assertSee('Eight Days Ago')
->assertSee('Twenty Nine Days Ago')
->assertDontSee('Thirty One Days Ago');
});
test('all time counts every upvote', function () {
$ancient = lbProduct('Ancient Product');
lbUpvotes($ancient, 1, now()->subYears(2));
$this->get(route('leaderboard', ['period' => 'all']))->assertSee('Ancient Product');
});
test('only approved products with upvotes in the period are ranked', function () {
$ok = lbProduct('Ranked Product');
$pending = lbProduct('Pending Product', 'pending');
$rejected = lbProduct('Rejected Product', 'rejected');
lbProduct('Zero Upvotes');
lbUpvotes($ok, 1);
lbUpvotes($pending, 4);
lbUpvotes($rejected, 4);
$this->get(route('leaderboard'))
->assertSee('Ranked Product')
->assertDontSee('Pending Product')
->assertDontSee('Rejected Product')
->assertDontSee('Zero Upvotes');
});
test('ties are broken by total upvotes, then by newest', function () {
$moreTotal = lbProduct('More Total', attrs: ['created_at' => now()->subDays(10)]);
$fewerTotal = lbProduct('Fewer Total', attrs: ['created_at' => now()->subDays(5)]);
$newerTie = lbProduct('Newer Tie', attrs: ['created_at' => now()->subDay()]);
$olderTie = lbProduct('Older Tie', attrs: ['created_at' => now()->subDays(2)]);
lbUpvotes($moreTotal, 2);
lbUpvotes($moreTotal, 3, now()->subDays(40));
lbUpvotes($fewerTotal, 2);
lbUpvotes($newerTie, 1);
lbUpvotes($olderTie, 1);
$this->get(route('leaderboard', ['period' => 'today']))
->assertSeeInOrder(['More Total', 'Fewer Total', 'Newer Tie', 'Older Tie']);
});
test('a removed upvote is not counted', function () {
$product = lbProduct('Changed Mind');
lbUpvotes($product, 2);
Upvote::where('product_id', $product->id)->first()->delete();
$this->get(route('leaderboard'))->assertSee('1 today', false);
});
test('new tab lists approved products newest first', function () {
lbProduct('Oldest One', attrs: ['created_at' => now()->subDays(3)]);
lbProduct('Newest One', attrs: ['created_at' => now()->subHour()]);
lbProduct('Middle One', attrs: ['created_at' => now()->subDay()]);
lbProduct('Pending One', 'pending');
$this->get(route('leaderboard', ['tab' => 'new']))
->assertOk()
->assertSeeInOrder(['Newest One', 'Middle One', 'Oldest One'])
->assertDontSee('Pending One');
});
test('lists 10 per page, ranks continue on page 2 and links keep the filters', function () {
foreach (range(1, 12) as $i) {
lbUpvotes(lbProduct("Product $i"), 20 - $i);
}
$page1 = $this->get(route('leaderboard', ['period' => 'week']));
$page1->assertSee('data-rank="10"', false)
->assertDontSee('data-rank="11"', false)
->assertSee('period=week', false)
->assertSee('page=2', false);
$this->get(route('leaderboard', ['period' => 'week', 'page' => 2]))
->assertSee('data-rank="11"', false)
->assertSee('data-rank="12"', false)
->assertSee('Product 12')
->assertDontSee('Product 1<', false);
});
test('invalid tab and period fall back to top today', function () {
$product = lbProduct('Fallback Product');
lbUpvotes($product, 1);
$this->get(route('leaderboard', ['tab' => 'nonsense', 'period' => 'decade']))
->assertOk()
->assertSee('Fallback Product');
});
test('empty period shows a message', function () {
$this->get(route('leaderboard', ['period' => 'today']))
->assertOk()
->assertSee('No upvotes yet today');
});
test('navbar links to the leaderboard', function () {
$this->get(route('leaderboard'))->assertSee(route('leaderboard'), false);
$this->get('/products')->assertSee(route('leaderboard'), false);
});