diff --git a/app/Concerns/ClearsCache.php b/app/Concerns/ClearsCache.php new file mode 100644 index 0000000..6a0e640 --- /dev/null +++ b/app/Concerns/ClearsCache.php @@ -0,0 +1,24 @@ + self::clearCache($model)); + self::updated(fn (self $model) => self::clearCache($model)); + self::deleted(fn (self $model) => self::clearCache($model)); + } + + private static function clearCache(self $model): void + { + Cache::tags($model->getCacheTags())->flush(); + } +} diff --git a/app/Contracts/ClearsCache.php b/app/Contracts/ClearsCache.php deleted file mode 100644 index fa68ad3..0000000 --- a/app/Contracts/ClearsCache.php +++ /dev/null @@ -1,10 +0,0 @@ -clearCache($this->electionId); - } } public function middleware(): array diff --git a/app/Livewire/NewsFeed.php b/app/Livewire/NewsFeed.php index eb6bb0d..e50c60a 100644 --- a/app/Livewire/NewsFeed.php +++ b/app/Livewire/NewsFeed.php @@ -6,7 +6,8 @@ use App\Models\Article; use App\Models\Election; -use Illuminate\Pagination\LengthAwarePaginator; +use App\Services\CacheService; +use Illuminate\Support\Collection; use Livewire\Attributes\Computed; use Livewire\Component; use Livewire\WithPagination; @@ -28,13 +29,16 @@ public function reload(): void } #[Computed] - protected function articles(): LengthAwarePaginator + protected function articles(): Collection { - return Article::query() - ->whereBelongsTo($this->election) - ->with('author.media', 'media') - ->onlyPublished() - ->orderByDesc('published_at') - ->paginate(50); + return CacheService::make('articles', $this->election) + ->remember( + fn () => Article::query() + ->whereBelongsTo($this->election) + ->with('author.media', 'media') + ->onlyPublished() + ->orderByDesc('published_at') + ->get() + ); } } diff --git a/app/Livewire/Pages/ElectionPage.php b/app/Livewire/Pages/ElectionPage.php index c44daba..2ad11bd 100644 --- a/app/Livewire/Pages/ElectionPage.php +++ b/app/Livewire/Pages/ElectionPage.php @@ -9,6 +9,7 @@ use App\Models\County; use App\Models\Election; use App\Models\Locality; +use App\Services\CacheService; use ArchTech\SEO\SEOManager; use Filament\Forms\Components\Grid; use Filament\Forms\Components\Select; @@ -46,6 +47,7 @@ abstract class ElectionPage extends Component implements HasForms public function mount() { $this->checkDefaultPage(); + $validation = Validator::make([ 'country' => $this->country, 'county' => $this->county, @@ -91,13 +93,17 @@ public function form(Form $form): Form ->label(__('app.field.country')) ->placeholder(__('app.field.country')) ->hiddenLabel() - ->options( - fn () => Country::query() - ->whereHas($whereHasKey, function (Builder $query) { - $query->whereBelongsTo($this->election); - }) - ->pluck('name', 'id') - ) + ->options(function () use ($whereHasKey) { + return CacheService::make(['countries', $whereHasKey], $this->election) + ->remember( + fn () => Country::query() + ->whereHas($whereHasKey, function (Builder $query) { + $query->whereBelongsTo($this->election) + ->whereNotNull('country_id'); + }) + ->pluck('name', 'id') + ); + }) ->afterStateUpdated(function (Set $set) { $set('county', null); $set('locality', null); @@ -110,13 +116,17 @@ public function form(Form $form): Form ->label(__('app.field.county')) ->placeholder(__('app.field.county')) ->hiddenLabel() - ->options( - fn () => County::query() - ->whereHas($whereHasKey, function (Builder $query) { - $query->whereBelongsTo($this->election); - }) - ->pluck('name', 'id') - ) + ->options(function () use ($whereHasKey) { + return CacheService::make(['counties', $whereHasKey], $this->election) + ->remember( + fn () => County::query() + ->whereHas($whereHasKey, function (Builder $query) use ($whereHasKey) { + $query->whereBelongsTo($this->election) + ->whereNotNull("{$whereHasKey}.county_id"); + }) + ->pluck('name', 'id') + ); + }) ->afterStateUpdated(function (Set $set) { $set('locality', null); }) @@ -128,15 +138,24 @@ public function form(Form $form): Form ->label(__('app.field.locality')) ->hiddenLabel() ->placeholder(__('app.field.locality')) - ->options( - fn (Get $get) => Locality::query() - ->where('county_id', $get('county')) - ->whereHas($whereHasKey, function (Builder $query) { - $query->whereBelongsTo($this->election); - }) - ->limit(1000) - ->pluck('name', 'id') - ) + ->options(function (Get $get) use ($whereHasKey) { + $county_id = $get('county'); + + return CacheService::make(['localities', $whereHasKey], $this->election, county: $county_id) + ->remember( + fn () => Locality::query() + ->where('county_id', $county_id) + ->whereHas($whereHasKey, function (Builder $query) use ($county_id) { + $query->whereBelongsTo($this->election); + + if ($county_id !== 403) { + $query->whereNull('parent_id'); + } + }) + ->limit(150) + ->pluck('name', 'id') + ); + }) ->visible(fn (Get $get) => DataLevel::isValue($get('level'), DataLevel::NATIONAL) && ! \is_null($get('county'))) ->searchable() diff --git a/app/Livewire/Pages/ElectionResults.php b/app/Livewire/Pages/ElectionResults.php index d2d63a3..c191acf 100644 --- a/app/Livewire/Pages/ElectionResults.php +++ b/app/Livewire/Pages/ElectionResults.php @@ -4,7 +4,6 @@ namespace App\Livewire\Pages; -use App\Enums\Time; use App\Models\Candidate; use App\Models\Party; use App\Models\Vote; @@ -34,7 +33,7 @@ public function render(): View #[Computed] public function parties(): Collection { - return Cache::remember("parties:{$this->election->id}", Time::DAY_IN_SECONDS->value, function () { + return Cache::remember("parties:{$this->election->id}", now()->addDay(), function () { return Party::query() ->whereBelongsTo($this->election) // ->whereHas('votes', function (Builder $query) { @@ -48,7 +47,7 @@ public function parties(): Collection #[Computed] public function candidates(): Collection { - return Cache::remember("candidates:{$this->election->id}", Time::DAY_IN_SECONDS->value, function () { + return Cache::remember("candidates:{$this->election->id}", now()->addDay(), function () { return Candidate::query() ->whereBelongsTo($this->election) // ->whereHas('votes', function (Builder $query) { diff --git a/app/Livewire/VoteMonitorStats.php b/app/Livewire/VoteMonitorStats.php index eaf1875..021fd85 100644 --- a/app/Livewire/VoteMonitorStats.php +++ b/app/Livewire/VoteMonitorStats.php @@ -6,6 +6,7 @@ use App\Models\Election; use App\Models\VoteMonitorStat; +use App\Services\CacheService; use Livewire\Attributes\Computed; use Livewire\Component; @@ -18,12 +19,15 @@ class VoteMonitorStats extends Component #[Computed] public function stats(): array { - return VoteMonitorStat::query() - ->whereBelongsTo($this->election) - ->where('enabled', true) - ->orderBy('order') - ->get() - ->toArray(); + return CacheService::make('vote-monitor-stats', $this->election) + ->remember( + fn () => VoteMonitorStat::query() + ->whereBelongsTo($this->election) + ->where('enabled', true) + ->orderBy('order') + ->get() + ->toArray() + ); } #[Computed] diff --git a/app/Models/Article.php b/app/Models/Article.php index 1e58843..65e5ded 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Concerns\BelongsToElection; +use App\Concerns\ClearsCache; use App\Concerns\Publishable; use App\Enums\User\Role; use Database\Factories\ArticleFactory; @@ -18,6 +19,7 @@ class Article extends Model implements HasMedia { + use ClearsCache; use HasFactory; use BelongsToElection; use InteractsWithMedia; @@ -74,4 +76,11 @@ public function registerMediaCollections(): void ->optimize(); }); } + + public function getCacheTags(): array + { + return [ + "election:{$this->election_id}:articles", + ]; + } } diff --git a/app/Models/Candidate.php b/app/Models/Candidate.php index 402de60..a7f95c8 100644 --- a/app/Models/Candidate.php +++ b/app/Models/Candidate.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Concerns\BelongsToElection; +use App\Concerns\ClearsCache; use App\Contracts\HasDisplayName; use Database\Factories\CandidateFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -19,6 +20,7 @@ class Candidate extends Model implements HasMedia, HasDisplayName { use BelongsToElection; + use ClearsCache; /** @use HasFactory */ use HasFactory; use InteractsWithMedia; @@ -74,4 +76,11 @@ public function getDisplayName(): string { return $this->display_name ?? $this->name; } + + public function getCacheTags(): array + { + return [ + "candidates:{$this->election_id}", + ]; + } } diff --git a/app/Models/Election.php b/app/Models/Election.php index 7fd2d6b..250460e 100644 --- a/app/Models/Election.php +++ b/app/Models/Election.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Concerns\ClearsCache; use App\Concerns\HasSlug; use App\Enums\ElectionType; use Database\Factories\ElectionFactory; @@ -17,6 +18,7 @@ class Election extends Model implements HasName, HasAvatar { + use ClearsCache; /** @use HasFactory */ use HasFactory; use HasSlug; @@ -127,4 +129,9 @@ public function getFilamentAvatarUrl(): ?string return 'https://ui-avatars.com/api/?name=E'; } + + public function getCacheTags(): array + { + return ['elections']; + } } diff --git a/app/Models/Menu.php b/app/Models/Menu.php new file mode 100644 index 0000000..cebec7e --- /dev/null +++ b/app/Models/Menu.php @@ -0,0 +1,18 @@ + */ use HasFactory; use InteractsWithMedia; @@ -77,4 +79,12 @@ public function getDisplayName(): string { return $this->name; } + + public function getCacheTags(): array + { + return [ + "parties:{$this->election_id}", + "candidates:{$this->election_id}", + ]; + } } diff --git a/app/Models/Record.php b/app/Models/Record.php index f0f2c30..9f84428 100644 --- a/app/Models/Record.php +++ b/app/Models/Record.php @@ -7,11 +7,9 @@ use App\Concerns\BelongsToElection; use App\Concerns\CanGroupByDataLevel; use App\Concerns\HasTemporaryTable; -use App\Contracts\ClearsCache; use App\Contracts\TemporaryTable; use App\Enums\DataLevel; use App\Enums\Part; -use App\Repositories\RecordsRepository; use Database\Factories\RecordFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,7 +18,7 @@ use Tpetry\QueryExpressions\Function\Aggregate\Sum; use Tpetry\QueryExpressions\Language\Alias; -class Record extends Model implements TemporaryTable, ClearsCache +class Record extends Model implements TemporaryTable { use BelongsToElection; use CanGroupByDataLevel; @@ -115,9 +113,4 @@ public function getTemporaryTableUniqueColumns(): array { return ['election_id', 'county_id', 'country_id', 'section']; } - - public function clearCache(int $electionId): bool - { - return RecordsRepository::clearCache($electionId); - } } diff --git a/app/Models/Turnout.php b/app/Models/Turnout.php index 3ea089c..59fe9fb 100644 --- a/app/Models/Turnout.php +++ b/app/Models/Turnout.php @@ -7,11 +7,9 @@ use App\Concerns\BelongsToElection; use App\Concerns\CanGroupByDataLevel; use App\Concerns\HasTemporaryTable; -use App\Contracts\ClearsCache; use App\Contracts\TemporaryTable; use App\Enums\Area; use App\Enums\DataLevel; -use App\Repositories\TurnoutRepository; use Database\Factories\TurnoutFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -21,7 +19,7 @@ use Tpetry\QueryExpressions\Function\Aggregate\Sum; use Tpetry\QueryExpressions\Language\Alias; -class Turnout extends Model implements TemporaryTable, ClearsCache +class Turnout extends Model implements TemporaryTable { use BelongsToElection; use CanGroupByDataLevel; @@ -190,9 +188,4 @@ public function getNameAttribute(): string return $this->loadMissing('country')->country->name; } } - - public function clearCache(int $electionId): bool - { - return TurnoutRepository::clearCache($electionId); - } } diff --git a/app/Models/Vote.php b/app/Models/Vote.php index 0261af8..2594844 100644 --- a/app/Models/Vote.php +++ b/app/Models/Vote.php @@ -7,11 +7,9 @@ use App\Concerns\BelongsToElection; use App\Concerns\CanGroupByDataLevel; use App\Concerns\HasTemporaryTable; -use App\Contracts\ClearsCache; use App\Contracts\TemporaryTable; use App\Enums\DataLevel; use App\Enums\Part; -use App\Repositories\VotesRepository; use Database\Factories\VoteFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -21,7 +19,7 @@ use Tpetry\QueryExpressions\Function\Aggregate\Sum; use Tpetry\QueryExpressions\Language\Alias; -class Vote extends Model implements TemporaryTable, ClearsCache +class Vote extends Model implements TemporaryTable { use BelongsToElection; use CanGroupByDataLevel; @@ -96,9 +94,4 @@ public function getTemporaryTableUniqueColumns(): array { return ['election_id', 'county_id', 'country_id', 'section', 'votable_type', 'votable_id']; } - - public function clearCache(int $electionId): bool - { - return VotesRepository::clearCache($electionId); - } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ffcf02d..052f612 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -16,7 +16,6 @@ use Illuminate\Encryption\MissingAppKeyException; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Vite; use Illuminate\Support\Number; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; @@ -103,6 +102,9 @@ protected function enforceMorphMap(): void 'county' => \App\Models\County::class, 'election' => \App\Models\Election::class, 'locality' => \App\Models\Locality::class, + 'menu_item' => \App\Models\MenuItem::class, + 'menu_location' => \App\Models\MenuLocation::class, + 'menu' => \App\Models\Menu::class, 'page' => \App\Models\Page::class, 'party' => \App\Models\Party::class, 'record' => \App\Models\Record::class, diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index c8013ae..fce0bea 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -8,6 +8,9 @@ use App\Filament\Admin\Resources\ElectionResource; use App\Filament\Admin\Resources\MenuResource; use App\Models\Election; +use App\Models\Menu; +use App\Models\MenuItem; +use App\Models\MenuLocation; use App\Models\Page; use Datlechin\FilamentMenuBuilder\FilamentMenuBuilderPlugin; use Datlechin\FilamentMenuBuilder\MenuPanel\ModelMenuPanel; @@ -67,6 +70,9 @@ public function panel(Panel $panel): Panel FilamentMenuBuilderPlugin::make() ->usingResource(MenuResource::class) + ->usingMenuModel(Menu::class) + ->usingMenuItemModel(MenuItem::class) + ->usingMenuLocationModel(MenuLocation::class) ->addLocations([ 'header' => 'Header', 'footer' => 'Footer', diff --git a/app/Services/CacheService.php b/app/Services/CacheService.php index c762b88..d5a4d61 100644 --- a/app/Services/CacheService.php +++ b/app/Services/CacheService.php @@ -5,7 +5,6 @@ namespace App\Services; use App\Enums\DataLevel; -use App\Enums\Time; use App\Models\Election; use Closure; use Illuminate\Support\Arr; @@ -13,7 +12,14 @@ class CacheService { - protected int $ttl = Time::DAY_IN_SECONDS->value; + /** + * TTLs for stale-while-revalidate cache. + * Fresh for 45s, revalidate up to 24h. + * @var array + */ + protected array $ttl = [ + 45, 86400, + ]; protected array $tags = []; @@ -32,7 +38,7 @@ public function __construct( ) { $this->tags = $this->getTags(); - $this->key = "election:{$this->getElectionId()}:{$this->getName()}:{$level?->value}:{$country}:{$county}:{$locality}:{$aggregate}:{$toBase}:{$this->getAddSelect()}"; + $this->key = $this->getKey(); } public static function make( @@ -70,10 +76,7 @@ public function setTTL(int $ttl): static public function remember(Closure $callback): mixed { - return match (Cache::supportsTags() && filled($this->tags)) { - true => Cache::tags($this->tags)->remember($this->key, $this->ttl, $callback), - default => Cache::remember($this->key, $this->ttl, $callback) - }; + return Cache::tags($this->tags)->flexible($this->key, $this->ttl, $callback); } protected function getElectionId(): int @@ -95,6 +98,22 @@ protected function getAddSelect(): string return implode('-', $this->addSelect); } + protected function getKey(): string + { + return collect([ + 'election', + $this->getElectionId(), + $this->getName(), + $this->level?->value, + $this->country, + $this->county, + $this->locality, + $this->aggregate, + $this->toBase, + $this->getAddSelect(), + ])->join(':'); + } + protected function getTags(): array { $prefix = "election:{$this->getElectionId()}"; diff --git a/app/View/Components/Election/Title.php b/app/View/Components/Election/Title.php index 474d6fe..2fd710f 100644 --- a/app/View/Components/Election/Title.php +++ b/app/View/Components/Election/Title.php @@ -5,7 +5,6 @@ namespace App\View\Components\Election; use App\Enums\DataLevel; -use App\Enums\Time; use App\Models\Country; use App\Models\County; use App\Models\Locality; @@ -60,7 +59,7 @@ protected function getCountry(?string $id): ?Country return Cache::remember( "country-{$id}", - Time::DAY_IN_SECONDS, + now()->addDay(), fn () => Country::find($id) ); } @@ -73,7 +72,7 @@ protected function getCounty(?int $id): ?County return Cache::remember( "county-{$id}", - Time::DAY_IN_SECONDS, + now()->addDay(), fn () => County::find($id) ); } @@ -86,7 +85,7 @@ protected function getLocality(?int $id): ?Locality return Cache::remember( "locality-{$id}", - Time::DAY_IN_SECONDS, + now()->addDay(), fn () => Locality::find($id) ); } diff --git a/app/View/Components/Site/Footer.php b/app/View/Components/Site/Footer.php index a2602ca..61ca905 100644 --- a/app/View/Components/Site/Footer.php +++ b/app/View/Components/Site/Footer.php @@ -4,8 +4,7 @@ namespace App\View\Components\Site; -use App\Enums\Time; -use Datlechin\FilamentMenuBuilder\Models\Menu; +use App\Models\Menu; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -26,15 +25,8 @@ public function __construct() protected function getMenuItems(): Collection { - return Cache::remember('footer-menu', Time::DAY_IN_SECONDS, function () { - $menu = Menu::location('footer'); - - if (blank($menu)) { - return collect(); - } - - return $menu->menuItems; - }); + return Cache::tags('menus') + ->rememberForever('footer-menu', fn () => Menu::location('footer')?->menuItems); } protected function getSocialItems(): Collection diff --git a/app/View/Components/Site/Header.php b/app/View/Components/Site/Header.php index 536c3d8..542bdb4 100644 --- a/app/View/Components/Site/Header.php +++ b/app/View/Components/Site/Header.php @@ -4,8 +4,7 @@ namespace App\View\Components\Site; -use App\Enums\Time; -use Datlechin\FilamentMenuBuilder\Models\Menu; +use App\Models\Menu; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -26,15 +25,8 @@ public function __construct(bool $timeline = false) protected function getMenuItems(): Collection { - return Cache::remember('header-menu', Time::DAY_IN_SECONDS, function () { - $menu = Menu::location('header'); - - if (blank($menu)) { - return collect(); - } - - return $menu->menuItems; - }); + return Cache::tags('menus') + ->rememberForever('header-menu', fn () => Menu::location('header')?->menuItems); } public function render(): View diff --git a/app/View/Components/Timeline.php b/app/View/Components/Timeline.php index ba09cb9..548fa17 100644 --- a/app/View/Components/Timeline.php +++ b/app/View/Components/Timeline.php @@ -7,6 +7,7 @@ use App\Models\Election; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\View\Component; class Timeline extends Component @@ -20,11 +21,15 @@ class Timeline extends Component public function __construct() { - $this->years = Election::all() - ->groupBy([ - 'year', - fn (Election $election) => $election->type->getLabel(), - ]); + $this->years = Cache::tags(['elections']) + ->rememberForever( + 'elections-timeline', + fn () => Election::all() + ->groupBy([ + 'year', + fn (Election $election) => $election->type->getLabel(), + ]) + ); $this->election = request()->election; } diff --git a/resources/views/livewire/news-feed.blade.php b/resources/views/livewire/news-feed.blade.php index b283f0f..01069d8 100644 --- a/resources/views/livewire/news-feed.blade.php +++ b/resources/views/livewire/news-feed.blade.php @@ -12,12 +12,9 @@
- {{ $this->articles->links(data: ['scrollTo' => false]) }} @foreach ($this->articles as $article) @endforeach - - {{ $this->articles->links(data: ['scrollTo' => '#newsfeed']) }}