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
+
+
+ Showing stats for user ID: {{ $stats['filtered_user_id'] }}
+ Showing stats for all users. 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->previous_count }} → {{ $emote->current_count }}
+ {{ $emote->delta >= 0 ? '+' : '' }}{{ $emote->delta }}
+ No mover data available yet. No static emote usage found. No animated emote usage found. No unicode emote usage found.Emoji Stats
+
+ Emote Usage Over Time (Last 30 Days)
+
+ Top Movers (Last 7 Days vs Previous 7 Days)
+
+
+ {{ $emote->emote_name }}
+
Top 10 Static Emotes
+
+
+ {{ $emote->emote_name }}
+
Top 10 Animated Emotes
+
+
+ {{ $emote->emote_name }}
+
Top 10 Unicode Emotes
+
+