Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89 lines
3.0 KiB
PHP
89 lines
3.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Widgets;
|
|
|
|
use App\Models\ContentView;
|
|
use App\Support\ContentTypes;
|
|
use Filament\Widgets\StatsOverviewWidget;
|
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* What the site is actually being read for, this month.
|
|
*
|
|
* The counters this panel started with said how many projects existed. This
|
|
* says which three anybody opened, which is the number that changes what
|
|
* somebody does next: it is the difference between a portfolio nobody visits
|
|
* and one page carrying the whole site.
|
|
*
|
|
* Absent entirely until there is something to show. A widget reading "0, 0, 0"
|
|
* on an installation whose frontend does not report views would be teaching
|
|
* everybody to ignore that corner of the dashboard.
|
|
*/
|
|
class MostRead extends StatsOverviewWidget
|
|
{
|
|
protected static ?int $sort = 2;
|
|
|
|
protected const DAYS = 30;
|
|
|
|
public static function canView(): bool
|
|
{
|
|
return (Auth::user()?->can('panel.access') ?? false)
|
|
&& ContentView::query()->since(self::DAYS)->exists();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, Stat>
|
|
*/
|
|
protected function getStats(): array
|
|
{
|
|
return collect($this->top())
|
|
->map(fn (array $row) => Stat::make($row['title'], (string) $row['count'])
|
|
->description(__('panel.views.in_days', ['days' => self::DAYS]))
|
|
->descriptionIcon('heroicon-m-eye')
|
|
->color('info'))
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* The three most-read records of any kind, resolved to their titles.
|
|
*
|
|
* Cached for a few minutes: this is drawn on the dashboard, which is the
|
|
* first page everybody lands on, and the answer does not change minute to
|
|
* minute — the underlying figures are daily totals.
|
|
*
|
|
* @return array<int, array{title: string, count: int}>
|
|
*/
|
|
protected function top(): array
|
|
{
|
|
return Cache::remember('views:most-read', now()->addMinutes(5), function (): array {
|
|
$rows = ContentView::query()
|
|
->since(self::DAYS)
|
|
->selectRaw('viewable_type, viewable_id, SUM(count) as total')
|
|
->groupBy('viewable_type', 'viewable_id')
|
|
->orderByDesc('total')
|
|
->limit(3)
|
|
->get();
|
|
|
|
// Loaded per group rather than with the relation, because these
|
|
// are three rows of different types and eager loading a morph for
|
|
// three records buys nothing.
|
|
return $rows
|
|
->map(function (ContentView $row): ?array {
|
|
$record = $row->viewable;
|
|
|
|
return $record instanceof Model
|
|
? ['title' => ContentTypes::title($record), 'count' => (int) $row->total]
|
|
: null;
|
|
})
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
});
|
|
}
|
|
}
|