Add a statistics page to the panel

The most read, reads per language with a daily chart, and published content
nobody opened, each compared with the period before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 10:19:19 +02:00
parent 8d0f81f9c5
commit 697d333ad4
9 changed files with 605 additions and 0 deletions

View File

@@ -1173,6 +1173,14 @@ cosas salen el mismo lunes y nada en quince días. El mes va en la URL
(`?month=2026-10`) para poder mandarlo, y en el móvil pasa a una lista con solo (`?month=2026-10`) para poder mandarlo, y en el móvil pasa a una lista con solo
los días que tienen algo. Cada persona ve los tipos que puede listar. los días que tienen algo. Cada persona ve los tipos que puede listar.
**Estadísticas** (Contenido → Estadísticas) — de las lecturas que ya manda el
frontend (totales por día, registro e idioma; nada sobre la persona): lo más
leído, las lecturas de cada idioma con su parte del total y una gráfica diaria,
y lo que **está en la web y nadie abrió** en el periodo —o cuesta encontrarlo o
no compensa—. 7, 30 o 90 días, y cada cifra comparada con el periodo anterior
de la misma duración, porque «320 lecturas» dice poco y «320, desde 210» dice
si algo funcionó.
**Próximos vencimientos** — en el dashboard, las convocatorias que cierran y **Próximos vencimientos** — en el dashboard, las convocatorias que cierran y
el contenido que sale de la web solo (`unpublish_at`) en las próximas dos el contenido que sale de la web solo (`unpublish_at`) en las próximas dos
semanas, a quien puede editarlos. Y `content:expiring`, cada día a las 8, avisa semanas, a quien puede editarlos. Y `content:expiring`, cada día a las 8, avisa

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Filament\Charts;
use App\Support\ViewStats;
use Filament\Widgets\ChartWidget;
/**
* Reads per day, one line per language. Lives outside `Filament/Widgets` so
* the dashboard does not discover it: it belongs to the statistics page,
* which says which period to draw.
*/
class ViewsByLanguageChart extends ChartWidget
{
public int $days = 30;
protected int|string|array $columnSpan = 'full';
protected ?string $maxHeight = '280px';
public function getHeading(): string
{
return __('panel.statistics.daily');
}
protected function getType(): string
{
return 'line';
}
protected function getData(): array
{
$daily = (new ViewStats($this->days))->daily();
$colors = ['#f59e0b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6'];
return [
'labels' => $daily['labels'],
'datasets' => collect($daily['series'])
->values()
->map(fn (array $data, int $i) => [
'label' => config('locales.supported.'.array_keys($daily['series'])[$i].'.native'),
'data' => $data,
'borderColor' => $colors[$i % count($colors)],
'backgroundColor' => $colors[$i % count($colors)],
'tension' => 0.3,
])
->all(),
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Support\ViewStats;
use BackedEnum;
use Filament\Pages\Page as FilamentPage;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Url;
use UnitEnum;
/**
* What people read: the most read, how each language is doing, and what is
* on the site that nobody opened. Counts only — what the frontend reports is
* a daily total per record and language, with nothing about the person.
*/
class Statistics extends FilamentPage
{
public const PERIODS = [7, 30, 90];
protected string $view = 'filament.pages.statistics';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
protected static ?int $navigationSort = 6;
#[Url]
public int $days = 30;
public function mount(): void
{
if (! in_array($this->days, self::PERIODS, true)) {
$this->days = 30;
}
}
public function updatedDays(): void
{
$this->mount();
}
public static function canAccess(): bool
{
return Auth::user()?->can('panel.access') ?? false;
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.content');
}
public static function getNavigationLabel(): string
{
return __('panel.statistics.title');
}
public function getTitle(): string|Htmlable
{
return __('panel.statistics.title');
}
public function stats(): ViewStats
{
return new ViewStats($this->days);
}
/**
* "+52 %", "−10 %", or "new" when there was nothing to compare with.
*/
public function change(int $now, int $before): string
{
if ($before === 0) {
return $now > 0 ? __('panel.statistics.new') : '—';
}
$percent = (int) round(($now - $before) * 100 / $before);
return ($percent > 0 ? '+' : ($percent < 0 ? '−' : '')).abs($percent).' %';
}
}

192
app/Support/ViewStats.php Normal file
View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Filament\Resources\Calls\CallResource;
use App\Filament\Resources\Members\MemberResource;
use App\Models\Call;
use App\Models\ContentView;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* What was read, from the daily per-language totals the frontend reports.
*
* Every figure is compared with the period just before it of the same length:
* "320 reads" says little, "320, up from 210" says whether something worked.
*/
final class ViewStats
{
/**
* @var array<string, class-string<Model>>
*/
protected const TYPES = [
'pages' => Page::class,
'projects' => Project::class,
'services' => Service::class,
'calls' => Call::class,
'members' => Member::class,
];
public function __construct(public readonly int $days) {}
public function from(): CarbonImmutable
{
return CarbonImmutable::today()->subDays($this->days - 1);
}
public function previousFrom(): CarbonImmutable
{
return $this->from()->subDays($this->days);
}
/**
* @return Collection<int, array{type: string, title: string, url: ?string, views: int, previous: int}>
*/
public function top(int $limit = 10): Collection
{
$current = ContentView::query()
->where('day', '>=', $this->from()->toDateString())
->selectRaw('viewable_type, viewable_id, SUM(count) as total')
->groupBy('viewable_type', 'viewable_id')
->orderByDesc('total')
->limit($limit)
->get();
$previous = ContentView::query()
->whereBetween('day', [$this->previousFrom()->toDateString(), $this->from()->subDay()->toDateString()])
->where(function ($q) use ($current): void {
foreach ($current as $row) {
$q->orWhere(fn ($q) => $q->where('viewable_type', $row->viewable_type)->where('viewable_id', $row->viewable_id));
}
})
->selectRaw('viewable_type, viewable_id, SUM(count) as total')
->groupBy('viewable_type', 'viewable_id')
->get()
->keyBy(fn ($row) => $row->viewable_type.':'.$row->viewable_id);
return $current
->map(function ($row) use ($previous): ?array {
$record = $row->viewable;
if (! $record instanceof Model) {
return null;
}
return [
'type' => array_search($record::class, self::TYPES, true) ?: class_basename($record),
'title' => $record instanceof Member ? $record->name : ContentTypes::title($record),
'url' => self::url($record),
'views' => (int) $row->total,
'previous' => (int) ($previous[$row->viewable_type.':'.$row->viewable_id]->total ?? 0),
];
})
->filter()
->values();
}
/**
* Reads per language, now and in the period before.
*
* @return Collection<string, array{views: int, previous: int, share: float}>
*/
public function byLocale(): Collection
{
$sum = fn (string $from, string $to) => ContentView::query()
->whereBetween('day', [$from, $to])
->selectRaw('locale, SUM(count) as total')
->groupBy('locale')
->pluck('total', 'locale');
$now = $sum($this->from()->toDateString(), CarbonImmutable::today()->toDateString());
$before = $sum($this->previousFrom()->toDateString(), $this->from()->subDay()->toDateString());
$total = max(1, (int) $now->sum());
return collect(array_keys((array) config('locales.supported')))
->mapWithKeys(fn (string $locale) => [$locale => [
'views' => (int) ($now[$locale] ?? 0),
'previous' => (int) ($before[$locale] ?? 0),
'share' => round(((int) ($now[$locale] ?? 0)) * 100 / $total, 1),
]]);
}
/**
* Day by day, per language, for the chart.
*
* @return array{labels: array<int, string>, series: array<string, array<int, int>>}
*/
public function daily(): array
{
$rows = ContentView::query()
->where('day', '>=', $this->from()->toDateString())
->selectRaw('day, locale, SUM(count) as total')
->groupBy('day', 'locale')
->get()
->groupBy(fn ($row) => CarbonImmutable::parse($row->day)->toDateString());
$labels = [];
$series = array_fill_keys(array_keys((array) config('locales.supported')), []);
for ($day = $this->from(); $day->lte(CarbonImmutable::today()); $day = $day->addDay()) {
$labels[] = $day->isoFormat('D MMM');
$today = ($rows[$day->toDateString()] ?? collect())->pluck('total', 'locale');
foreach (array_keys($series) as $locale) {
$series[$locale][] = (int) ($today[$locale] ?? 0);
}
}
return ['labels' => $labels, 'series' => $series];
}
/**
* On the site and read by nobody in the period: the content that is
* either hard to find or not worth finding.
*
* @return Collection<int, array{type: string, title: string, url: ?string}>
*/
public function unread(int $limit = 50): Collection
{
$items = collect();
foreach (self::TYPES as $type => $model) {
$read = ContentView::query()
->where('viewable_type', (new $model)->getMorphClass())
->where('day', '>=', $this->from()->toDateString())
->distinct()
->pluck('viewable_id');
$model::query()
->when($model === Call::class, fn ($q) => $q->open())
->when($model === Member::class, fn ($q) => $q->public())
->unless(in_array($model, [Call::class, Member::class], true), fn ($q) => $q->published())
->whereNotIn((new $model)->getKeyName(), $read)
->limit($limit)
->get()
->each(fn (Model $record) => $items->push([
'type' => $type,
'title' => $record instanceof Member ? $record->name : ContentTypes::title($record),
'url' => self::url($record),
]));
}
return $items->take($limit)->values();
}
protected static function url(Model $record): ?string
{
return match (true) {
$record instanceof Call => CallResource::getUrl('edit', ['record' => $record]),
$record instanceof Member => MemberResource::getUrl('edit', ['record' => $record]),
$record instanceof Page, $record instanceof Project, $record instanceof Service => ReviewTarget::url($record),
default => null,
};
}
}

View File

@@ -144,6 +144,34 @@ return [
'select_all' => 'Selecciona-ho tot', 'select_all' => 'Selecciona-ho tot',
], ],
'statistics' => [
'title' => 'Estadístiques',
'period' => 'Període',
'last_days' => 'Últims :days dies',
'languages' => 'Lectures per idioma',
'compared' => 'Comparat amb el període anterior de la mateixa durada.',
'daily' => 'Lectures per dia',
'top' => 'El més llegit',
'no_views' => 'Encara no hi ha lectures en aquest període.',
'unread' => 'Al web i sense lectures',
'unread_hint' => 'Publicat, i ningú no ho va obrir en el període: o costa de trobar, o no compensa.',
'all_read' => 'Tot el que és publicat ha tingut alguna lectura.',
'new' => 'nou',
'columns' => [
'title' => 'Títol',
'type' => 'Tipus',
'views' => 'Lectures',
'change' => 'Canvi',
],
'types' => [
'pages' => 'Pàgina',
'projects' => 'Projecte',
'services' => 'Servei',
'calls' => 'Convocatòria',
'members' => 'Membre',
],
],
'calendar' => [ 'calendar' => [
'title' => 'Calendari editorial', 'title' => 'Calendari editorial',
'legend' => 'Llegenda', 'legend' => 'Llegenda',

View File

@@ -144,6 +144,34 @@ return [
'select_all' => 'Select all', 'select_all' => 'Select all',
], ],
'statistics' => [
'title' => 'Statistics',
'period' => 'Period',
'last_days' => 'Last :days days',
'languages' => 'Reads by language',
'compared' => 'Compared with the previous period of the same length.',
'daily' => 'Reads per day',
'top' => 'Most read',
'no_views' => 'No reads in this period yet.',
'unread' => 'On the site and unread',
'unread_hint' => 'Published, and nobody opened it in the period: either hard to find, or not worth finding.',
'all_read' => 'Everything published was read at least once.',
'new' => 'new',
'columns' => [
'title' => 'Title',
'type' => 'Type',
'views' => 'Reads',
'change' => 'Change',
],
'types' => [
'pages' => 'Page',
'projects' => 'Project',
'services' => 'Service',
'calls' => 'Call',
'members' => 'Member',
],
],
'calendar' => [ 'calendar' => [
'title' => 'Editorial calendar', 'title' => 'Editorial calendar',
'legend' => 'Legend', 'legend' => 'Legend',

View File

@@ -144,6 +144,34 @@ return [
'select_all' => 'Seleccionar todos', 'select_all' => 'Seleccionar todos',
], ],
'statistics' => [
'title' => 'Estadísticas',
'period' => 'Periodo',
'last_days' => 'Últimos :days días',
'languages' => 'Lecturas por idioma',
'compared' => 'Comparado con el periodo anterior de la misma duración.',
'daily' => 'Lecturas por día',
'top' => 'Lo más leído',
'no_views' => 'Aún no hay lecturas en este periodo.',
'unread' => 'En la web y sin lecturas',
'unread_hint' => 'Publicado, y nadie lo abrió en el periodo: o cuesta encontrarlo, o no compensa.',
'all_read' => 'Todo lo publicado tuvo alguna lectura.',
'new' => 'nuevo',
'columns' => [
'title' => 'Título',
'type' => 'Tipo',
'views' => 'Lecturas',
'change' => 'Cambio',
],
'types' => [
'pages' => 'Página',
'projects' => 'Proyecto',
'services' => 'Servicio',
'calls' => 'Convocatoria',
'members' => 'Miembro',
],
],
'calendar' => [ 'calendar' => [
'title' => 'Calendario editorial', 'title' => 'Calendario editorial',
'legend' => 'Leyenda', 'legend' => 'Leyenda',

View File

@@ -0,0 +1,102 @@
<x-filament-panels::page>
<style>
.st-periods { display: flex; gap: .5rem; flex-wrap: wrap; }
.st-table { width: 100%; border-collapse: collapse; font-size: .875rem; }
.st-table th { text-align: left; font-weight: 600; font-size: .75rem; text-transform: uppercase; opacity: .7; padding: .5rem; }
.st-table td { padding: .5rem; border-top: 1px solid color-mix(in oklab, currentColor 10%, transparent); }
.st-table td.num, .st-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
.st-up { color: #16a34a; } .st-down { color: #dc2626; }
.st-locales { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: 1rem; }
.st-locale strong { display: block; font-size: 1.5rem; font-variant-numeric: tabular-nums; }
.st-bar { height: .375rem; border-radius: 999px; background: color-mix(in oklab, currentColor 10%, transparent); overflow: hidden; margin-top: .5rem; }
.st-bar span { display: block; height: 100%; background: var(--primary-500, #f59e0b); }
.st-unread { columns: 2 18rem; column-gap: 2rem; font-size: .875rem; }
.st-unread li { break-inside: avoid; padding: .25rem 0; }
</style>
@php($stats = $this->stats())
<div class="st-periods" role="group" aria-label="{{ __('panel.statistics.period') }}">
@foreach (\App\Filament\Pages\Statistics::PERIODS as $period)
<x-filament::button
:color="$days === $period ? 'primary' : 'gray'"
wire:click="$set('days', {{ $period }})"
:aria-pressed="$days === $period ? 'true' : 'false'"
>
{{ __('panel.statistics.last_days', ['days' => $period]) }}
</x-filament::button>
@endforeach
</div>
<x-filament::section :heading="__('panel.statistics.languages')" :description="__('panel.statistics.compared')">
<div class="st-locales">
@foreach ($stats->byLocale() as $locale => $row)
@php($delta = $this->change($row['views'], $row['previous']))
<div class="st-locale">
<span>{{ config("locales.supported.{$locale}.native", $locale) }}</span>
<strong>{{ number_format($row['views'], 0, ',', '.') }}</strong>
<span @class(['st-up' => str_starts_with($delta, '+'), 'st-down' => str_starts_with($delta, '−')])>{{ $delta }}</span>
· {{ $row['share'] }} %
<div class="st-bar" aria-hidden="true"><span style="width: {{ $row['share'] }}%"></span></div>
</div>
@endforeach
</div>
</x-filament::section>
@livewire(\App\Filament\Charts\ViewsByLanguageChart::class, ['days' => $days], key('views-chart-'.$days))
<x-filament::section :heading="__('panel.statistics.top')">
@php($top = $stats->top())
@if ($top->isEmpty())
<p>{{ __('panel.statistics.no_views') }}</p>
@else
<table class="st-table">
<thead>
<tr>
<th scope="col">{{ __('panel.statistics.columns.title') }}</th>
<th scope="col">{{ __('panel.statistics.columns.type') }}</th>
<th scope="col" class="num">{{ __('panel.statistics.columns.views') }}</th>
<th scope="col" class="num">{{ __('panel.statistics.columns.change') }}</th>
</tr>
</thead>
<tbody>
@foreach ($top as $row)
@php($delta = $this->change($row['views'], $row['previous']))
<tr>
<td>
@if ($row['url'])
<x-filament::link :href="$row['url']">{{ $row['title'] }}</x-filament::link>
@else
{{ $row['title'] }}
@endif
</td>
<td>{{ __('panel.statistics.types.'.$row['type']) }}</td>
<td class="num">{{ number_format($row['views'], 0, ',', '.') }}</td>
<td @class(['num', 'st-up' => str_starts_with($delta, '+'), 'st-down' => str_starts_with($delta, '−')])>{{ $delta }}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</x-filament::section>
<x-filament::section :heading="__('panel.statistics.unread')" :description="__('panel.statistics.unread_hint')" collapsible>
@php($unread = $stats->unread())
@if ($unread->isEmpty())
<p>{{ __('panel.statistics.all_read') }}</p>
@else
<ul class="st-unread">
@foreach ($unread as $row)
<li>
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __('panel.statistics.types.'.$row['type']) }}</x-filament::badge>
@if ($row['url'])
<x-filament::link :href="$row['url']">{{ $row['title'] }}</x-filament::link>
@else
{{ $row['title'] }}
@endif
</li>
@endforeach
</ul>
@endif
</x-filament::section>
</x-filament-panels::page>

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Pages\Statistics;
use App\Models\ContentView;
use App\Models\Page;
use App\Models\Project;
use App\Support\ViewStats;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class StatisticsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
$this->travelTo('2026-10-15 12:00:00');
}
#[Test]
public function the_most_read_compare_with_the_period_before(): void
{
$page = Page::factory()->published()->create(['title' => ['es' => 'Leída']]);
$this->travelTo('2026-09-10 12:00:00');
ContentView::record($page, 'es', 10);
$this->travelTo('2026-10-14 12:00:00');
ContentView::record($page, 'es', 15);
ContentView::record($page, 'en', 5);
$this->travelTo('2026-10-15 12:00:00');
$stats = new ViewStats(30);
$top = $stats->top()->first();
$this->assertSame('Leída', $top['title']);
$this->assertSame(20, $top['views']);
$this->assertSame(10, $top['previous']);
$this->assertSame(['views' => 15, 'previous' => 10, 'share' => 75.0], $stats->byLocale()['es']);
$this->assertSame(30, count($stats->daily()['labels']));
}
#[Test]
public function published_content_nobody_read_is_listed(): void
{
$read = Page::factory()->published()->create();
Page::factory()->published()->create(['title' => ['es' => 'Olvidada']]);
Page::factory()->draft()->create(['title' => ['es' => 'Borrador']]);
ContentView::record($read, 'es');
$titles = (new ViewStats(30))->unread()->pluck('title');
$this->assertContains('Olvidada', $titles);
$this->assertNotContains('Borrador', $titles);
$this->assertNotContains($read->getTranslation('title', 'es'), $titles);
}
#[Test]
public function the_page_opens_and_switches_period(): void
{
$this->actingAs($this->userWithRoles('writer'));
Project::factory()->published()->create();
$this->get(Statistics::getUrl())->assertOk();
Livewire::test(Statistics::class)
->set('days', 7)
->assertSet('days', 7)
->set('days', 12)
->assertSet('days', 30);
}
}