diff --git a/app/Http/Controllers/LeaderboardController.php b/app/Http/Controllers/LeaderboardController.php new file mode 100644 index 0000000..a45c6c6 --- /dev/null +++ b/app/Http/Controllers/LeaderboardController.php @@ -0,0 +1,35 @@ +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, + ]); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index 25c1200..0305fdd 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -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. */ diff --git a/app/Models/Upvote.php b/app/Models/Upvote.php index 040d36e..ccf5bd6 100644 --- a/app/Models/Upvote.php +++ b/app/Models/Upvote.php @@ -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. * diff --git a/resources/views/components/layout/navbar.blade.php b/resources/views/components/layout/navbar.blade.php index 81d80b0..3acdc0d 100644 --- a/resources/views/components/layout/navbar.blade.php +++ b/resources/views/components/layout/navbar.blade.php @@ -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 + + Leaderboard + @@ -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 + + Leaderboard + @auth @if(auth()->user()->hasRole('admin')) $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') +
+

Leaderboard

+

The most upvoted and the newest products from European makers.

+ + +
+ Top + New +
+ + + @if($tab === 'top') +
+ @foreach($periods as $key => $label) + {{ $label }} + @endforeach +
+ @endif + + + @if($products->isEmpty()) +
+ @if($tab === 'new') +

No products yet.

+ @else +

No upvotes yet {{ $periodLabel ?? 'at all' }}

+

Try a longer period.

+ @endif +
+ @else +
    + @foreach($products as $product) +
  1. + {{ $products->firstItem() + $loop->index }} + + + @if($product->thumbnail_url) + {{ $product->title }} + @else +
    + {{ strtoupper(substr($product->title, 0, 1)) }} +
    + @endif +
    + +
    + {{ $product->title }} +

    {{ Str::limit($product->description, 100) }}

    +
    + @foreach($product->tags->take(3) as $tag) + #{{ $tag->name }} + @endforeach + +
    +
    + +
    + @include('components.product.upvote-button', ['product' => $product, 'size' => 'md']) + @if($tab === 'top' && $periodLabel) +

    {{ $product->period_upvotes_count }} {{ $periodLabel }}

    + @endif +
    +
  2. + @endforeach +
+ +
{{ $products->links() }}
+ @endif +
+@endsection diff --git a/routes/web.php b/routes/web.php index 08233f4..959aca3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/LeaderboardTest.php b/tests/Feature/LeaderboardTest.php new file mode 100644 index 0000000..dc0d767 --- /dev/null +++ b/tests/Feature/LeaderboardTest.php @@ -0,0 +1,169 @@ +{$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); +});