From 16999ecea58454be7109dbafafa09e2ff8fc1888 Mon Sep 17 00:00:00 2001 From: itsAzaria Date: Fri, 20 Feb 2026 12:44:52 +0000 Subject: [PATCH] add website to view stats --- app/Bot.php | 12 +- app/Http/Controllers/HomepageController.php | 67 +++++ app/Models/EmoteGuildStat.php | 107 +++++++ app/Models/EmoteLog.php | 188 ++++++++++++ app/Models/UserGuildStat.php | 81 +++++ composer.json | 2 +- composer.lock | 2 +- resources/views/components/homepage.blade.php | 283 ++++++++++++++++++ .../views/components/layouts/app.blade.php | 14 + 9 files changed, 744 insertions(+), 12 deletions(-) create mode 100644 app/Http/Controllers/HomepageController.php create mode 100644 resources/views/components/homepage.blade.php create mode 100644 resources/views/components/layouts/app.blade.php diff --git a/app/Bot.php b/app/Bot.php index b68429c..d0cb1bc 100644 --- a/app/Bot.php +++ b/app/Bot.php @@ -2,6 +2,7 @@ namespace App; +use App\Http\Controllers\HomepageController; use Illuminate\Support\Facades\Route; use Laracord\Laracord; @@ -12,15 +13,6 @@ class Bot extends Laracord */ public function routes(): void { - Route::middleware('web')->group(function () { - // Route::get('/', fn () => 'Hello world!'); - }); - - Route::middleware('api')->group(function () { - // Route::get('/commands', fn () => collect($this->registeredCommands)->map(fn ($command) => [ - // 'signature' => $command->getSignature(), - // 'description' => $command->getDescription(), - // ])); - }); + Route::get('/', HomepageController::class); } } diff --git a/app/Http/Controllers/HomepageController.php b/app/Http/Controllers/HomepageController.php new file mode 100644 index 0000000..b4cc392 --- /dev/null +++ b/app/Http/Controllers/HomepageController.php @@ -0,0 +1,67 @@ +query('user_id', '')); + $userIdFilter = ctype_digit($rawUserIdFilter) ? $rawUserIdFilter : ''; + $isFiltered = $userIdFilter !== ''; + + $validationErrors = []; + + if ($rawUserIdFilter !== '' && ! ctype_digit($rawUserIdFilter)) { + $validationErrors['user_id'] = 'User ID must contain only numbers.'; + } + + $stats = $this->buildStats($userIdFilter, $isFiltered); + + return view('components.homepage', [ + 'stats' => $stats, + 'userIdInput' => $rawUserIdFilter, + 'validationErrors' => $validationErrors, + ]); + } + + /** + * Build homepage stats. + */ + protected function buildStats(string $userIdFilter, bool $isFiltered): array + { + $resolvedUserId = $isFiltered ? $userIdFilter : null; + + if ($isFiltered) { + $aggregate = UserGuildStat::dashboardAggregateForUser($userIdFilter); + } else { + $aggregate = EmoteGuildStat::dashboardAggregate(); + } + + $usageOverTime = EmoteLog::usageOverTime($resolvedUserId, 30); + $topMovers = EmoteLog::topMovers($resolvedUserId, 7, 10); + $uniqueUsers = $isFiltered ? (int) ($aggregate['unique_users'] ?? 0) : EmoteLog::uniqueUsersCount(); + + return [ + 'is_filtered' => $isFiltered, + 'filtered_user_id' => $isFiltered ? $userIdFilter : null, + 'total_usage' => (int) ($aggregate['total_usage'] ?? 0), + 'unique_emotes' => (int) ($aggregate['unique_emotes'] ?? 0), + 'unique_users' => $uniqueUsers, + 'usage_by_type' => $aggregate['usage_by_type'] ?? ['STATIC' => 0, 'ANIMATED' => 0, 'UNICODE' => 0], + 'usage_over_time' => $usageOverTime, + 'top_movers' => $topMovers, + 'top_static' => $aggregate['top_static'] ?? collect(), + 'top_animated' => $aggregate['top_animated'] ?? collect(), + 'top_unicode' => $aggregate['top_unicode'] ?? collect(), + ]; + } +} diff --git a/app/Models/EmoteGuildStat.php b/app/Models/EmoteGuildStat.php index 683a419..ebaf606 100644 --- a/app/Models/EmoteGuildStat.php +++ b/app/Models/EmoteGuildStat.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; class EmoteGuildStat extends Model { @@ -25,4 +26,110 @@ class EmoteGuildStat extends Model 'emote_id' ); } + + /** + * Build aggregate stats for all users. + */ + public static function dashboardAggregate(): array + { + $baseQuery = self::query() + ->join('emotes', 'emote_guild_stats.emote_id', '=', 'emotes.emote_id'); + + $summary = (clone $baseQuery) + ->selectRaw('COALESCE(SUM(emote_guild_stats.usage_count), 0) as total_usage') + ->selectRaw('COUNT(DISTINCT emote_guild_stats.emote_id) as unique_emotes') + ->first(); + + $usageByType = (clone $baseQuery) + ->select('emotes.type') + ->selectRaw('COALESCE(SUM(emote_guild_stats.usage_count), 0) as total_usage') + ->groupBy('emotes.type') + ->pluck('total_usage', 'type'); + + $unicodeUsage = (int) EmoteLog::query() + ->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id') + ->where('emotes.type', 'UNICODE') + ->count(); + + $unicodeUniqueEmotes = (int) EmoteLog::query() + ->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id') + ->where('emotes.type', 'UNICODE') + ->distinct('emote_logs.emote_id') + ->count('emote_logs.emote_id'); + + $topUnicode = EmoteLog::query() + ->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id') + ->where('emotes.type', 'UNICODE') + ->select( + 'emote_logs.emote_id', + 'emotes.emote_name', + 'emotes.type', + 'emotes.image' + ) + ->selectRaw('COUNT(*) as total_usage') + ->groupBy( + 'emote_logs.emote_id', + 'emotes.emote_name', + 'emotes.type', + 'emotes.image' + ) + ->orderByDesc('total_usage') + ->limit(10) + ->get(); + + $baseTotalUsage = (int) ($summary->total_usage ?? 0); + $baseUniqueEmotes = (int) ($summary->unique_emotes ?? 0); + + return [ + 'total_usage' => $baseTotalUsage + $unicodeUsage, + 'unique_emotes' => $baseUniqueEmotes + $unicodeUniqueEmotes, + 'usage_by_type' => [ + 'STATIC' => (int) ($usageByType->get('STATIC') ?? 0), + 'ANIMATED' => (int) ($usageByType->get('ANIMATED') ?? 0), + 'UNICODE' => $unicodeUsage, + ], + 'top_static' => self::topEmotesByType( + $baseQuery, + 'STATIC', + 'emote_guild_stats.usage_count', + 'emote_guild_stats.emote_id' + ), + 'top_animated' => self::topEmotesByType( + $baseQuery, + 'ANIMATED', + 'emote_guild_stats.usage_count', + 'emote_guild_stats.emote_id' + ), + 'top_unicode' => $topUnicode, + ]; + } + + /** + * Build a top-10 emote list by type. + */ + protected static function topEmotesByType( + $baseQuery, + string $type, + string $usageCountColumn, + string $emoteIdColumn + ): Collection { + return (clone $baseQuery) + ->where('emotes.type', $type) + ->select( + $emoteIdColumn.' as emote_id', + 'emotes.emote_name', + 'emotes.type', + 'emotes.image' + ) + ->selectRaw("COALESCE(SUM({$usageCountColumn}), 0) as total_usage") + ->groupBy( + $emoteIdColumn, + 'emotes.emote_name', + 'emotes.type', + 'emotes.image' + ) + ->orderByDesc('total_usage') + ->limit(10) + ->get(); + } } diff --git a/app/Models/EmoteLog.php b/app/Models/EmoteLog.php index c2f9621..b553fc4 100644 --- a/app/Models/EmoteLog.php +++ b/app/Models/EmoteLog.php @@ -3,6 +3,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; class EmoteLog extends Model { @@ -36,4 +38,190 @@ class EmoteLog extends Model { return $this->belongsTo(User::class, 'user_id', 'discord_id'); } + + /** + * Count distinct users for logs. + */ + public static function uniqueUsersCount(?string $userId = null): int + { + return (int) self::query() + ->when($userId !== null, fn ($query) => $query->where('user_id', $userId)) + ->distinct('user_id') + ->count('user_id'); + } + + /** + * Build usage-over-time dataset. + */ + public static function usageOverTime(?string $userId, int $days = 30): array + { + $today = now(); + $startDate = $today->copy()->subDays($days - 1)->startOfDay(); + + $rows = self::query() + ->when($userId !== null, fn ($query) => $query->where('user_id', $userId)) + ->where('created_at', '>=', $startDate) + ->selectRaw('DATE(created_at) as usage_date') + ->selectRaw('COUNT(*) as usage_count') + ->groupBy('usage_date') + ->orderBy('usage_date') + ->pluck('usage_count', 'usage_date'); + + $dateRange = collect(range($days - 1, 0))->map(fn (int $offset) => $today->copy()->subDays($offset)); + + return [ + 'labels' => $dateRange->map(fn ($date) => $date->format('m/d'))->values(), + 'values' => $dateRange->map( + fn ($date) => (int) ($rows->get($date->format('Y-m-d')) ?? 0) + )->values(), + ]; + } + + /** + * Build stacked daily trend data by emote type. + */ + public static function stackedDailyTrend(?string $userId, int $days = 30): array + { + $today = now(); + $startDate = $today->copy()->subDays($days - 1)->startOfDay(); + + $rows = self::query() + ->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id') + ->when($userId !== null, fn ($query) => $query->where('emote_logs.user_id', $userId)) + ->where('emote_logs.created_at', '>=', $startDate) + ->selectRaw('DATE(emote_logs.created_at) as usage_date') + ->select('emotes.type') + ->selectRaw('COUNT(*) as usage_count') + ->groupBy('usage_date', 'emotes.type') + ->get(); + + $indexed = $rows->mapWithKeys(function ($row) { + return [$row->usage_date.'|'.$row->type => (int) $row->usage_count]; + }); + + $dateRange = collect(range($days - 1, 0))->map(fn (int $offset) => $today->copy()->subDays($offset)); + $labels = $dateRange->map(fn ($date) => $date->format('m/d'))->values(); + + $valuesForType = function (string $type) use ($dateRange, $indexed) { + return $dateRange->map(function ($date) use ($type, $indexed) { + $key = $date->format('Y-m-d').'|'.$type; + + return (int) ($indexed->get($key) ?? 0); + })->values(); + }; + + return [ + 'labels' => $labels, + 'datasets' => [ + ['label' => 'Static', 'values' => $valuesForType('STATIC')], + ['label' => 'Animated', 'values' => $valuesForType('ANIMATED')], + ['label' => 'Unicode', 'values' => $valuesForType('UNICODE')], + ], + ]; + } + + /** + * Build day/hour heatmap data. + */ + public static function heatmap(?string $userId, int $days = 30): array + { + $startDate = now()->subDays($days - 1)->startOfDay(); + + $logs = self::query() + ->when($userId !== null, fn ($query) => $query->where('user_id', $userId)) + ->where('created_at', '>=', $startDate) + ->get(['created_at']); + + $daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + $hours = range(0, 23); + $matrix = []; + + foreach ($daysOfWeek as $day) { + $matrix[$day] = array_fill(0, 24, 0); + } + + foreach ($logs as $log) { + $timestamp = Carbon::parse($log->created_at); + $day = $daysOfWeek[$timestamp->dayOfWeek]; + $hour = $timestamp->hour; + $matrix[$day][$hour]++; + } + + $max = collect($matrix)->flatten()->max() ?: 0; + + return [ + 'days' => $daysOfWeek, + 'hours' => $hours, + 'matrix' => $matrix, + 'max' => $max, + ]; + } + + /** + * Build top movers from current vs previous window. + */ + public static function topMovers(?string $userId, int $windowDays = 7, int $limit = 10): Collection + { + $now = now(); + $currentStart = $now->copy()->subDays($windowDays - 1)->startOfDay(); + $previousStart = $currentStart->copy()->subDays($windowDays); + $previousEnd = $currentStart->copy()->subSecond(); + + $queryForRange = function (Carbon $start, Carbon $end) use ($userId) { + return self::query() + ->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id') + ->when($userId !== null, fn ($query) => $query->where('emote_logs.user_id', $userId)) + ->whereBetween('emote_logs.created_at', [$start, $end]) + ->select('emote_logs.emote_id', 'emotes.emote_name', 'emotes.type', 'emotes.image') + ->selectRaw('COUNT(*) as usage_count') + ->groupBy('emote_logs.emote_id', 'emotes.emote_name', 'emotes.type', 'emotes.image') + ->get(); + }; + + $currentRows = $queryForRange($currentStart, $now->copy()->endOfDay()); + $previousRows = $queryForRange($previousStart, $previousEnd); + + $metadata = collect(); + $currentById = collect(); + $previousById = collect(); + + foreach ($currentRows as $row) { + $metadata->put($row->emote_id, [ + 'emote_id' => $row->emote_id, + 'emote_name' => $row->emote_name, + 'type' => $row->type, + 'image' => $row->image, + ]); + $currentById->put($row->emote_id, (int) $row->usage_count); + } + + foreach ($previousRows as $row) { + if (! $metadata->has($row->emote_id)) { + $metadata->put($row->emote_id, [ + 'emote_id' => $row->emote_id, + 'emote_name' => $row->emote_name, + 'type' => $row->type, + 'image' => $row->image, + ]); + } + + $previousById->put($row->emote_id, (int) $row->usage_count); + } + + return $metadata + ->map(function (array $meta, string $emoteId) use ($currentById, $previousById) { + $current = (int) ($currentById->get($emoteId) ?? 0); + $previous = (int) ($previousById->get($emoteId) ?? 0); + $delta = $current - $previous; + + return (object) array_merge($meta, [ + 'current_count' => $current, + 'previous_count' => $previous, + 'delta' => $delta, + ]); + }) + ->sortByDesc(fn ($row) => abs($row->delta)) + ->take($limit) + ->values(); + } } diff --git a/app/Models/UserGuildStat.php b/app/Models/UserGuildStat.php index afc59d7..c749a07 100644 --- a/app/Models/UserGuildStat.php +++ b/app/Models/UserGuildStat.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; class UserGuildStat extends Model { @@ -23,4 +24,84 @@ class UserGuildStat extends Model { return $this->belongsTo(User::class, 'user_id', 'discord_id'); } + + /** + * Build aggregate stats for a single user. + */ + public static function dashboardAggregateForUser(string $userId): array + { + $baseQuery = self::query() + ->join('emotes', 'user_guild_stats.emote_id', '=', 'emotes.emote_id') + ->where('user_guild_stats.user_id', $userId); + + $summary = (clone $baseQuery) + ->selectRaw('COALESCE(SUM(user_guild_stats.usage_count), 0) as total_usage') + ->selectRaw('COUNT(DISTINCT user_guild_stats.emote_id) as unique_emotes') + ->selectRaw('COUNT(DISTINCT user_guild_stats.user_id) as unique_users') + ->first(); + + $usageByType = (clone $baseQuery) + ->select('emotes.type') + ->selectRaw('COALESCE(SUM(user_guild_stats.usage_count), 0) as total_usage') + ->groupBy('emotes.type') + ->pluck('total_usage', 'type'); + + return [ + 'total_usage' => (int) ($summary->total_usage ?? 0), + 'unique_emotes' => (int) ($summary->unique_emotes ?? 0), + 'unique_users' => (int) ($summary->unique_users ?? 0), + 'usage_by_type' => [ + 'STATIC' => (int) ($usageByType->get('STATIC') ?? 0), + 'ANIMATED' => (int) ($usageByType->get('ANIMATED') ?? 0), + 'UNICODE' => (int) ($usageByType->get('UNICODE') ?? 0), + ], + 'top_static' => self::topEmotesByType( + $baseQuery, + 'STATIC', + 'user_guild_stats.usage_count', + 'user_guild_stats.emote_id' + ), + 'top_animated' => self::topEmotesByType( + $baseQuery, + 'ANIMATED', + 'user_guild_stats.usage_count', + 'user_guild_stats.emote_id' + ), + 'top_unicode' => self::topEmotesByType( + $baseQuery, + 'UNICODE', + 'user_guild_stats.usage_count', + 'user_guild_stats.emote_id' + ), + ]; + } + + /** + * Build a top-10 emote list by type. + */ + protected static function topEmotesByType( + $baseQuery, + string $type, + string $usageCountColumn, + string $emoteIdColumn + ): Collection { + return (clone $baseQuery) + ->where('emotes.type', $type) + ->select( + $emoteIdColumn.' as emote_id', + 'emotes.emote_name', + 'emotes.type', + 'emotes.image' + ) + ->selectRaw("COALESCE(SUM({$usageCountColumn}), 0) as total_usage") + ->groupBy( + $emoteIdColumn, + 'emotes.emote_name', + 'emotes.type', + 'emotes.image' + ) + ->orderByDesc('total_usage') + ->limit(10) + ->get(); + } } diff --git a/composer.json b/composer.json index ec3f7a6..f03472f 100644 --- a/composer.json +++ b/composer.json @@ -52,4 +52,4 @@ }, "minimum-stability": "dev", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index ed9c64e..61348a8 100644 --- a/composer.lock +++ b/composer.lock @@ -8711,5 +8711,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/resources/views/components/homepage.blade.php b/resources/views/components/homepage.blade.php new file mode 100644 index 0000000..643cbca --- /dev/null +++ b/resources/views/components/homepage.blade.php @@ -0,0 +1,283 @@ +@php + $placeholderImage = 'data:image/svg+xml;base64,'.base64_encode('?'); + + $emoteImageSrc = function ($image) use ($placeholderImage) { + if (empty($image)) { + return $placeholderImage; + } + + $binary = (string) $image; + $mime = 'image/png'; + + if (str_starts_with($binary, 'GIF8')) { + $mime = 'image/gif'; + } elseif (substr($binary, 0, 2) === "\xFF\xD8") { + $mime = 'image/jpeg'; + } + + return 'data:'.$mime.';base64,'.base64_encode($binary); + }; + + $chartPayload = [ + 'usage_over_time' => [ + 'labels' => data_get($stats, 'usage_over_time.labels', []), + 'values' => data_get($stats, 'usage_over_time.values', []), + ], + ]; +@endphp + + +
+

Emoji Stats

+ +
+ +
+ + + Clear +
+ + @if (!empty($validationErrors['user_id'])) +
{{ $validationErrors['user_id'] }}
+ @endif + + @if (!empty($stats['is_filtered'])) +

+ Showing stats for user ID: {{ $stats['filtered_user_id'] }} +

+ @else +

Showing stats for all users.

+ @endif +
+ +
+
+
+

Total Emoji Usage

+

{{ $stats['total_usage'] ?? 0 }}

+
+ +
+

Unique Emotes

+

{{ $stats['unique_emotes'] ?? 0 }}

+
+ +
+

Users Included

+

{{ $stats['unique_users'] ?? 0 }}

+
+ +
+

Static Usage

+

{{ $stats['usage_by_type']['STATIC'] ?? 0 }}

+
+ +
+

Animated Usage

+

{{ $stats['usage_by_type']['ANIMATED'] ?? 0 }}

+
+ +
+

Unicode Usage

+

{{ $stats['usage_by_type']['UNICODE'] ?? 0 }}

+
+
+
+ +
+
+

Emote Usage Over Time (Last 30 Days)

+ +
+ +
+
+ +
+

Top Movers (Last 7 Days vs Previous 7 Days)

+ +
+ @forelse (($stats['top_movers'] ?? collect()) as $emote) +
+
+ {{ $emote->emote_name }} + {{ $emote->emote_name }} +
+
+

{{ $emote->previous_count }} → {{ $emote->current_count }}

+

+ {{ $emote->delta >= 0 ? '+' : '' }}{{ $emote->delta }} +

+
+
+ @empty +

No mover data available yet.

+ @endforelse +
+
+ +
+

Top 10 Static Emotes

+ +
+ @forelse (($stats['top_static'] ?? collect()) as $emote) +
+
+ #{{ $loop->iteration }} + {{ $emote->emote_name }} + {{ $emote->emote_name }} +
+ {{ $emote->total_usage }} +
+ @empty +

No static emote usage found.

+ @endforelse +
+
+ +
+

Top 10 Animated Emotes

+ +
+ @forelse (($stats['top_animated'] ?? collect()) as $emote) +
+
+ #{{ $loop->iteration }} + {{ $emote->emote_name }} + {{ $emote->emote_name }} +
+ {{ $emote->total_usage }} +
+ @empty +

No animated emote usage found.

+ @endforelse +
+
+ +
+

Top 10 Unicode Emotes

+ +
+ @forelse (($stats['top_unicode'] ?? collect()) as $emote) +
+
+ #{{ $loop->iteration }} + {{ $emote->emote_name }} +
+ {{ $emote->total_usage }} +
+ @empty +

No unicode emote usage found.

+ @endforelse +
+
+
+ + + + @once + + + @endonce +
+
diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php new file mode 100644 index 0000000..a867d25 --- /dev/null +++ b/resources/views/components/layouts/app.blade.php @@ -0,0 +1,14 @@ + + + + + + + {{ $title ?? config('app.name') }} + + + + + {{ $slot }} + +