From 8d0f81f9c51f783fcf5b548b30ff892ff757dae2 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 1 Oct 2026 10:17:46 +0200 Subject: [PATCH] Add an editorial calendar to the panel A month of publications, scheduled dates, content coming off the site, and calls opening and closing, each linked to its form. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++ app/Filament/Pages/EditorialCalendar.php | 123 ++++++++++++++++++ app/Support/EditorialCalendar.php | 74 +++++++++++ lang/ca/panel.php | 21 +++ lang/en/panel.php | 21 +++ lang/es/panel.php | 21 +++ .../pages/editorial-calendar.blade.php | 85 ++++++++++++ tests/Feature/Panel/EditorialCalendarTest.php | 61 +++++++++ 8 files changed, 414 insertions(+) create mode 100644 app/Filament/Pages/EditorialCalendar.php create mode 100644 app/Support/EditorialCalendar.php create mode 100644 resources/views/filament/pages/editorial-calendar.blade.php create mode 100644 tests/Feature/Panel/EditorialCalendarTest.php diff --git a/README.md b/README.md index 4c954d9..43cd849 100644 --- a/README.md +++ b/README.md @@ -1165,6 +1165,14 @@ abre en cuanto se crea. Antes la copia salía sin slug —un registro sin dirección, que la salud del sitio marcaba— y conservaba el estado: duplicar una página publicada daba otra «publicada» sin URL. +**Calendario editorial** (Contenido → Calendario editorial) — un mes de un +vistazo: lo que se publicó o se publicará (programado), lo que sale de la web, +y las convocatorias que abren y cierran, cada cosa enlazada a su formulario. Las +fechas ya estaban, de una en una en cada ficha; juntas es donde se ve que tres +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. + **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/Pages/EditorialCalendar.php b/app/Filament/Pages/EditorialCalendar.php new file mode 100644 index 0000000..b49c79c --- /dev/null +++ b/app/Filament/Pages/EditorialCalendar.php @@ -0,0 +1,123 @@ +month)) { + $this->month = now()->format('Y-m'); + } + } + + 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.calendar.title'); + } + + public function getTitle(): string|Htmlable + { + return __('panel.calendar.title'); + } + + public function previous(): void + { + $this->month = $this->first()->subMonth()->format('Y-m'); + } + + public function next(): void + { + $this->month = $this->first()->addMonth()->format('Y-m'); + } + + public function today(): void + { + $this->month = now()->format('Y-m'); + } + + public function first(): CarbonImmutable + { + return CarbonImmutable::createFromFormat('Y-m-d', $this->month.'-01')->startOfDay(); + } + + public function label(): string + { + return ucfirst($this->first()->locale(app()->getLocale())->isoFormat('MMMM YYYY')); + } + + /** + * Whole weeks, Monday first, covering the month. + * + * @return array>}>> + */ + public function weeks(): array + { + $start = $this->first()->startOfWeek(CarbonImmutable::MONDAY); + $end = $this->first()->endOfMonth()->endOfWeek(CarbonImmutable::SUNDAY); + + $events = Calendar::between($start, $end, Auth::user())->groupBy('date'); + + $weeks = []; + + for ($day = $start; $day->lte($end); $day = $day->addDay()) { + $weeks[intdiv((int) $start->diffInDays($day), 7)][] = [ + 'date' => $day, + 'inMonth' => $day->month === $this->first()->month, + 'isToday' => $day->isToday(), + 'events' => ($events[$day->toDateString()] ?? collect())->all(), + ]; + } + + return $weeks; + } + + /** + * @return array + */ + public function weekdays(): array + { + $monday = CarbonImmutable::now()->locale(app()->getLocale())->startOfWeek(CarbonImmutable::MONDAY); + + return array_map(fn (int $i) => $monday->addDays($i)->isoFormat('ddd'), range(0, 6)); + } +} diff --git a/app/Support/EditorialCalendar.php b/app/Support/EditorialCalendar.php new file mode 100644 index 0000000..b6d1a28 --- /dev/null +++ b/app/Support/EditorialCalendar.php @@ -0,0 +1,74 @@ + + */ + public static function between(CarbonImmutable $from, CarbonImmutable $to, User $user): Collection + { + $events = collect(); + + $add = function (string $type, Model $record, string $event, ?CarbonInterface $at) use ($events): void { + if ($at === null) { + return; + } + + $events->push([ + 'date' => $at->toDateString(), + 'type' => $type, + 'event' => $event, + 'title' => ContentTypes::title($record), + 'url' => $record instanceof Call ? CallResource::getUrl('edit', ['record' => $record]) : ReviewTarget::url($record), + 'at' => $at, + ]); + }; + + $inRange = fn ($query, string $column) => $query->whereBetween($column, [$from->startOfDay(), $to->endOfDay()]); + + $published = [ + 'pages' => [Page::class, fn ($q) => $q->where('status', PageStatus::Published)], + 'projects' => [Project::class, fn ($q) => $q], + 'services' => [Service::class, fn ($q) => $q->where('is_published', true)], + ]; + + foreach ($published as $type => [$model, $live]) { + if ($user->cannot('viewAny', $model)) { + continue; + } + + $live($inRange($model::query(), 'published_at'))->get() + ->each(fn (Model $r) => $add($type, $r, $r->published_at->isFuture() ? 'scheduled' : 'published', $r->published_at)); + + $live($inRange($model::query(), 'unpublish_at'))->get() + ->each(fn (Model $r) => $add($type, $r, 'unpublishes', $r->unpublish_at)); + } + + if ($user->can('viewAny', Call::class)) { + $inRange(Call::query(), 'opens_at')->get()->each(fn (Call $c) => $add('calls', $c, 'opens', $c->opens_at)); + $inRange(Call::query(), 'closes_at')->get()->each(fn (Call $c) => $add('calls', $c, 'closes', $c->closes_at)); + } + + return $events->sortBy(fn (array $e) => $e['at']->getTimestamp())->values(); + } +} diff --git a/lang/ca/panel.php b/lang/ca/panel.php index 445ef91..7930628 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -144,6 +144,27 @@ return [ 'select_all' => 'Selecciona-ho tot', ], + 'calendar' => [ + 'title' => 'Calendari editorial', + 'legend' => 'Llegenda', + 'previous' => 'Mes anterior', + 'next' => 'Mes següent', + 'today' => 'Avui', + 'events' => [ + 'published' => 'Publicat', + 'scheduled' => 'Programat', + 'unpublishes' => 'Surt del web', + 'opens' => 'Obre', + 'closes' => 'Tanca', + ], + 'types' => [ + 'pages' => 'Pàgina', + 'projects' => 'Projecte', + 'services' => 'Servei', + 'calls' => 'Convocatòria', + ], + ], + 'expiring' => [ 'heading' => 'Pròxims venciments', 'description' => 'Convocatòries que tanquen i contingut que surt del web en els pròxims :days dies.', diff --git a/lang/en/panel.php b/lang/en/panel.php index 0b5e837..cb21912 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -144,6 +144,27 @@ return [ 'select_all' => 'Select all', ], + 'calendar' => [ + 'title' => 'Editorial calendar', + 'legend' => 'Legend', + 'previous' => 'Previous month', + 'next' => 'Next month', + 'today' => 'Today', + 'events' => [ + 'published' => 'Published', + 'scheduled' => 'Scheduled', + 'unpublishes' => 'Comes off the site', + 'opens' => 'Opens', + 'closes' => 'Closes', + ], + 'types' => [ + 'pages' => 'Page', + 'projects' => 'Project', + 'services' => 'Service', + 'calls' => 'Call', + ], + ], + 'expiring' => [ 'heading' => 'Coming up', 'description' => 'Calls closing and content coming off the site in the next :days days.', diff --git a/lang/es/panel.php b/lang/es/panel.php index 698bbae..0f1241f 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -144,6 +144,27 @@ return [ 'select_all' => 'Seleccionar todos', ], + 'calendar' => [ + 'title' => 'Calendario editorial', + 'legend' => 'Leyenda', + 'previous' => 'Mes anterior', + 'next' => 'Mes siguiente', + 'today' => 'Hoy', + 'events' => [ + 'published' => 'Publicado', + 'scheduled' => 'Programado', + 'unpublishes' => 'Sale de la web', + 'opens' => 'Abre', + 'closes' => 'Cierra', + ], + 'types' => [ + 'pages' => 'Página', + 'projects' => 'Proyecto', + 'services' => 'Servicio', + 'calls' => 'Convocatoria', + ], + ], + 'expiring' => [ 'heading' => 'Próximos vencimientos', 'description' => 'Convocatorias que cierran y contenido que sale de la web en los próximos :days días.', diff --git a/resources/views/filament/pages/editorial-calendar.blade.php b/resources/views/filament/pages/editorial-calendar.blade.php new file mode 100644 index 0000000..ec1d350 --- /dev/null +++ b/resources/views/filament/pages/editorial-calendar.blade.php @@ -0,0 +1,85 @@ + + + +
+

{{ $this->label() }}

+ +
+ @foreach (['published', 'scheduled', 'unpublishes', 'opens', 'closes'] as $event) + {{ __('panel.calendar.events.'.$event) }} + @endforeach +
+ +
+ + {{ __('panel.calendar.previous') }} + + + {{ __('panel.calendar.today') }} + + + {{ __('panel.calendar.next') }} + +
+
+ + +
+ @foreach ($this->weekdays() as $weekday) +
{{ $weekday }}
+ @endforeach + + @foreach ($this->weeks() as $week) + @foreach ($week as $day) +
! $day['inMonth'], + 'is-today' => $day['isToday'], + 'is-empty' => $day['events'] === [], + ]) role="gridcell"> + + {{ $day['date']->locale(app()->getLocale())->isoFormat('dddd') }} + {{ $day['date']->day }} + + + @foreach ($day['events'] as $event) + + {{ __('panel.calendar.events.'.$event['event']) }}: + {{ __('panel.calendar.types.'.$event['type']) }} · {{ $event['title'] }} + + @endforeach +
+ @endforeach + @endforeach +
+
+
diff --git a/tests/Feature/Panel/EditorialCalendarTest.php b/tests/Feature/Panel/EditorialCalendarTest.php new file mode 100644 index 0000000..2950af6 --- /dev/null +++ b/tests/Feature/Panel/EditorialCalendarTest.php @@ -0,0 +1,61 @@ +seed(RolePermissionSeeder::class); + $this->travelTo('2026-10-15 10:00:00'); + } + + #[Test] + public function the_month_shows_what_goes_live_comes_off_opens_and_closes(): void + { + $this->actingAs($this->userWithRoles('editor')); + + Page::factory()->published()->create(['title' => ['es' => 'Ya publicada'], 'published_at' => '2026-10-02 09:00']); + Page::factory()->published()->create(['title' => ['es' => 'Programada'], 'published_at' => '2026-10-20 09:00']); + Page::factory()->draft()->create(['title' => ['es' => 'Borrador con fecha'], 'published_at' => '2026-10-21 09:00']); + Call::factory()->create(['title' => ['es' => 'Backend'], 'status' => CallStatus::Open, 'closes_at' => '2026-10-30']); + Page::factory()->published()->create(['title' => ['es' => 'Otro mes'], 'published_at' => '2026-12-01 09:00']); + + Livewire::withQueryParams(['month' => '2026-10']) + ->test(EditorialCalendar::class) + ->assertSee('Ya publicada') + ->assertSee('Programada') + ->assertSee('Backend') + ->assertDontSee('Borrador con fecha') + ->assertDontSee('Otro mes') + ->call('next') + ->assertSet('month', '2026-11') + ->call('today') + ->assertSet('month', '2026-10'); + } + + #[Test] + public function the_page_opens(): void + { + $this->actingAs($this->userWithRoles('writer')); + + $this->get(EditorialCalendar::getUrl())->assertOk(); + } +}