From 697d333ad433c10ef823fdf3a9194473e7094ac8 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 1 Oct 2026 10:19:19 +0200 Subject: [PATCH] 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 --- README.md | 8 + app/Filament/Charts/ViewsByLanguageChart.php | 52 +++++ app/Filament/Pages/Statistics.php | 84 ++++++++ app/Support/ViewStats.php | 192 ++++++++++++++++++ lang/ca/panel.php | 28 +++ lang/en/panel.php | 28 +++ lang/es/panel.php | 28 +++ .../views/filament/pages/statistics.blade.php | 102 ++++++++++ tests/Feature/Panel/StatisticsTest.php | 83 ++++++++ 9 files changed, 605 insertions(+) create mode 100644 app/Filament/Charts/ViewsByLanguageChart.php create mode 100644 app/Filament/Pages/Statistics.php create mode 100644 app/Support/ViewStats.php create mode 100644 resources/views/filament/pages/statistics.blade.php create mode 100644 tests/Feature/Panel/StatisticsTest.php diff --git a/README.md b/README.md index 43cd849..cf2d00d 100644 --- a/README.md +++ b/README.md @@ -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 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 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 diff --git a/app/Filament/Charts/ViewsByLanguageChart.php b/app/Filament/Charts/ViewsByLanguageChart.php new file mode 100644 index 0000000..66033a6 --- /dev/null +++ b/app/Filament/Charts/ViewsByLanguageChart.php @@ -0,0 +1,52 @@ +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(), + ]; + } +} diff --git a/app/Filament/Pages/Statistics.php b/app/Filament/Pages/Statistics.php new file mode 100644 index 0000000..643b944 --- /dev/null +++ b/app/Filament/Pages/Statistics.php @@ -0,0 +1,84 @@ +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).' %'; + } +} diff --git a/app/Support/ViewStats.php b/app/Support/ViewStats.php new file mode 100644 index 0000000..91dea02 --- /dev/null +++ b/app/Support/ViewStats.php @@ -0,0 +1,192 @@ +> + */ + 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 + */ + 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 + */ + 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, series: array>} + */ + 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 + */ + 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, + }; + } +} diff --git a/lang/ca/panel.php b/lang/ca/panel.php index 7930628..50e78bd 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -144,6 +144,34 @@ return [ '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' => [ 'title' => 'Calendari editorial', 'legend' => 'Llegenda', diff --git a/lang/en/panel.php b/lang/en/panel.php index cb21912..fa13b2d 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -144,6 +144,34 @@ return [ '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' => [ 'title' => 'Editorial calendar', 'legend' => 'Legend', diff --git a/lang/es/panel.php b/lang/es/panel.php index 0f1241f..dec293f 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -144,6 +144,34 @@ return [ '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' => [ 'title' => 'Calendario editorial', 'legend' => 'Leyenda', diff --git a/resources/views/filament/pages/statistics.blade.php b/resources/views/filament/pages/statistics.blade.php new file mode 100644 index 0000000..750cb54 --- /dev/null +++ b/resources/views/filament/pages/statistics.blade.php @@ -0,0 +1,102 @@ + + + + @php($stats = $this->stats()) + +
+ @foreach (\App\Filament\Pages\Statistics::PERIODS as $period) + + {{ __('panel.statistics.last_days', ['days' => $period]) }} + + @endforeach +
+ + +
+ @foreach ($stats->byLocale() as $locale => $row) + @php($delta = $this->change($row['views'], $row['previous'])) +
+ {{ config("locales.supported.{$locale}.native", $locale) }} + {{ number_format($row['views'], 0, ',', '.') }} + str_starts_with($delta, '+'), 'st-down' => str_starts_with($delta, '−')])>{{ $delta }} + · {{ $row['share'] }} % + +
+ @endforeach +
+
+ + @livewire(\App\Filament\Charts\ViewsByLanguageChart::class, ['days' => $days], key('views-chart-'.$days)) + + + @php($top = $stats->top()) + @if ($top->isEmpty()) +

{{ __('panel.statistics.no_views') }}

+ @else + + + + + + + + + + + @foreach ($top as $row) + @php($delta = $this->change($row['views'], $row['previous'])) + + + + + + + @endforeach + +
{{ __('panel.statistics.columns.title') }}{{ __('panel.statistics.columns.type') }}{{ __('panel.statistics.columns.views') }}{{ __('panel.statistics.columns.change') }}
+ @if ($row['url']) + {{ $row['title'] }} + @else + {{ $row['title'] }} + @endif + {{ __('panel.statistics.types.'.$row['type']) }}{{ number_format($row['views'], 0, ',', '.') }} str_starts_with($delta, '+'), 'st-down' => str_starts_with($delta, '−')])>{{ $delta }}
+ @endif +
+ + + @php($unread = $stats->unread()) + @if ($unread->isEmpty()) +

{{ __('panel.statistics.all_read') }}

+ @else +
    + @foreach ($unread as $row) +
  • + {{ __('panel.statistics.types.'.$row['type']) }} + @if ($row['url']) + {{ $row['title'] }} + @else + {{ $row['title'] }} + @endif +
  • + @endforeach +
+ @endif +
+
diff --git a/tests/Feature/Panel/StatisticsTest.php b/tests/Feature/Panel/StatisticsTest.php new file mode 100644 index 0000000..a7b11fb --- /dev/null +++ b/tests/Feature/Panel/StatisticsTest.php @@ -0,0 +1,83 @@ +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); + } +}