From 21aeac681596e8dcab94eebd69a6539dea262f06 Mon Sep 17 00:00:00 2001 From: aaron Date: Wed, 17 Apr 2024 23:45:55 -0500 Subject: [PATCH] feat: build initial dashboard page with ViewStats widget --- app/Enums/PresentationFilter.php | 22 +++++++ app/Enums/Traits/EnumToArray.php | 48 ++++++++++++++ app/Filament/Pages/Dashboard.php | 64 ++++++++++++++++++ app/Filament/Widgets/ViewStats.php | 65 +++++++++++++++++++ app/Models/AggregateView.php | 62 ++++++++++++++++++ app/Models/DailyView.php | 49 ++++++++++++++ app/Providers/Filament/AdminPanelProvider.php | 5 -- tests/Filament/PresentationResourceTest.php | 8 +++ 8 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 app/Enums/PresentationFilter.php create mode 100644 app/Enums/Traits/EnumToArray.php create mode 100644 app/Filament/Pages/Dashboard.php create mode 100644 app/Filament/Widgets/ViewStats.php diff --git a/app/Enums/PresentationFilter.php b/app/Enums/PresentationFilter.php new file mode 100644 index 0000000..eb5eb44 --- /dev/null +++ b/app/Enums/PresentationFilter.php @@ -0,0 +1,22 @@ + 'Instructions', + self::ADHOC => 'Adhoc', + }; + } +} diff --git a/app/Enums/Traits/EnumToArray.php b/app/Enums/Traits/EnumToArray.php new file mode 100644 index 0000000..c17f2cf --- /dev/null +++ b/app/Enums/Traits/EnumToArray.php @@ -0,0 +1,48 @@ +pluck('name') + ->toArray(); + } + + /** + * The array of values for the enum. + * + * @return mixed[] + */ + public static function values(): array + { + return collect(self::cases()) + ->pluck('value') + ->toArray(); + } + + /** + * The array of values for the enum. + * + * @return array + */ + public static function array(): array + { + return collect(self::cases()) + ->reduce(function ($carry, $row) { + $carry[$row->value] = $row->label(); + + return $carry; + }, []); + } +} diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php new file mode 100644 index 0000000..ec97e8a --- /dev/null +++ b/app/Filament/Pages/Dashboard.php @@ -0,0 +1,64 @@ +schema([ + Section::make() + ->schema([ + Select::make('presentation_id') + ->label('Presentation') + ->searchable() + ->options( + auth()->user()->isAdministrator() + ? PresentationFilter::array() + : [] + )->getSearchResultsUsing(function (string $search) { + return Presentation::forUser() + ->where('title', 'ilike', "%{$search}%") + ->limit(20) + ->pluck('title', 'id') + ->toArray(); + })->getOptionLabelUsing(function ($value): ?string { + return Presentation::forUser() + ->find(intval($value))?->title; + }), + DatePicker::make('start_date') + ->label('Start Date') + ->native(false) + ->maxDate(fn (Get $get): ?string => $get('end_date') ?? now()->subDay()) + ->hintIcon('heroicon-o-information-circle', tooltip: 'Only affects "Date Range" stats') + ->default(now()->subDays(8)), + DatePicker::make('end_date') + ->label('End Date') + ->native(false) + ->minDate(fn (Get $get): ?string => $get('start_date')) + ->default(now()->subDay()) + ->hintIcon('heroicon-o-information-circle', tooltip: 'Only affects "Date Range" stats') + ->maxDate(now()->subDay()), + ]) + ->columns(3), + ]); + } +} diff --git a/app/Filament/Widgets/ViewStats.php b/app/Filament/Widgets/ViewStats.php new file mode 100644 index 0000000..11d3590 --- /dev/null +++ b/app/Filament/Widgets/ViewStats.php @@ -0,0 +1,65 @@ +aggregateViews(withinRange: true), + $this->dailyViews(), + $this->aggregateViews(), + ]; + } + + private function dailyViews(): Stat + { + $views = DailyView::getForStat( + presentationId: $this->filters['presentation_id'] + ); + + $totalviews = $views->count(); + $uniqueviews = $views->unique(function (DailyView $item) { + return $item->presentation_id.$item->session_id; + })->count(); + + $percentUniqueviews = $totalviews == 0 + ? 0 + : round(($uniqueviews / $totalviews) * 100); + + return Stat::make('Total Views Today', $totalviews) + ->description($percentUniqueviews."% ($uniqueviews) Unique Views"); + } + + private function aggregateViews(bool $withinRange = false): Stat + { + $views = AggregateView::getForStat( + presentationId: $this->filters['presentation_id'], + startDate: $withinRange ? $this->filters['start_date'] : null, + endDate: $withinRange ? $this->filters['end_date'] : null, + ); + + $totalviews = $views->sum('total_count'); + $uniqueviews = $views->sum('unique_count'); + + $percentUniqueviews = $totalviews == 0 + ? 0 + : round(($uniqueviews / $totalviews) * 100); + + $heading = $withinRange ? 'Total Views in Date Range' : 'Total Lifetime Views'; + + return Stat::make($heading, $totalviews) + ->description($percentUniqueviews."% ($uniqueviews) Unique Views"); + } +} diff --git a/app/Models/AggregateView.php b/app/Models/AggregateView.php index 5f985cd..2037ad9 100644 --- a/app/Models/AggregateView.php +++ b/app/Models/AggregateView.php @@ -2,12 +2,74 @@ namespace App\Models; +use App\Enums\PresentationFilter; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; class AggregateView extends Model { use HasFactory; const UPDATED_AT = null; + + /** + * Get the aggregate views based on the presentation filter, and the user's role. + * + * @return Collection + */ + public static function getForStat( + ?string $presentationId, + ?string $startDate, + ?string $endDate, + ): Collection { + $query = self::forUser(); + + if ($startDate) { + $query->whereDate('created_at', '>=', $startDate); + } + + if ($endDate) { + $query->whereDate('created_at', '<=', $endDate); + } + + if (auth()->user()->isAdministrator()) { + if ($presentationId === PresentationFilter::INSTRUCTIONS->value) { + return $query + ->whereNull('presentation_id') + ->whereNull('adhoc_slug') + ->get(); + } + + if ($presentationId === PresentationFilter::ADHOC->value) { + return $query + ->whereNull('presentation_id') + ->whereNotNull('adhoc_slug') + ->get(); + } + } + + return is_null($presentationId) + ? $query->get() + : $query + ->where('presentation_id', intval($presentationId)) + ->get(); + } + + /** + * Scope a query to only include aggregate views for the authenticated user. + * + * @param Builder $query + */ + public function scopeForUser(Builder $query): void + { + if (auth()->user()->isAdministrator()) { + return; + } + + $presentationIds = auth()->user()->presentations()->pluck('id'); + + $query->whereIn('presentation_id', $presentationIds); + } } diff --git a/app/Models/DailyView.php b/app/Models/DailyView.php index 1b386bf..429bd26 100644 --- a/app/Models/DailyView.php +++ b/app/Models/DailyView.php @@ -2,9 +2,12 @@ namespace App\Models; +use App\Enums\PresentationFilter; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Collection; class DailyView extends Model { @@ -32,6 +35,52 @@ public static function createForAdhocPresentation(?string $slug = null): self ]); } + /** + * Get the daily views based on the presentation filter, and the user's role. + * + * @return Collection + */ + public static function getForStat(?string $presentationId): Collection + { + if (auth()->user()->isAdministrator()) { + if ($presentationId === PresentationFilter::INSTRUCTIONS->value) { + return self::forUser() + ->whereNull('presentation_id') + ->whereNull('adhoc_slug') + ->get(); + } + + if ($presentationId === PresentationFilter::ADHOC->value) { + return self::forUser() + ->whereNull('presentation_id') + ->whereNotNull('adhoc_slug') + ->get(); + } + } + + return is_null($presentationId) + ? self::forUser()->get() + : self::forUser() + ->where('presentation_id', intval($presentationId)) + ->get(); + } + + /** + * Scope a query to only include daily views for the authenticated user. + * + * @param Builder $query + */ + public function scopeForUser(Builder $query): void + { + if (auth()->user()->isAdministrator()) { + return; + } + + $presentationIds = auth()->user()->presentations()->pluck('id'); + + $query->whereIn('presentation_id', $presentationIds); + } + /** * The Presentation that this record belongs to * diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 287d965..e5590f3 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -10,7 +10,6 @@ use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; -use Filament\Widgets; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; @@ -38,10 +37,6 @@ public function panel(Panel $panel): Panel // Pages\Dashboard::class, ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') - ->widgets([ - Widgets\AccountWidget::class, - Widgets\FilamentInfoWidget::class, - ]) ->navigationGroups([ 'Main', 'Extras', diff --git a/tests/Filament/PresentationResourceTest.php b/tests/Filament/PresentationResourceTest.php index f33da0e..4cfd77c 100644 --- a/tests/Filament/PresentationResourceTest.php +++ b/tests/Filament/PresentationResourceTest.php @@ -50,6 +50,10 @@ it('can create a record', function () { $newData = Model::factory()->make(); + // The factory generates a random historical created_at, but we don't + // want that when creating a test record in Filament. + unset($newData['created_at']); + livewire(CreateResource::class) ->fillForm([ ...$newData->toArray(), @@ -231,6 +235,10 @@ it('can create a record', function () { $newData = Model::factory()->make(); + // The factory generates a random historical created_at, but we don't + // want that when creating a test record in Filament. + unset($newData['created_at']); + livewire(CreateResource::class) ->fillForm([ ...$newData->toArray(),