bottom chart and responsive changes

This commit is contained in:
itsAzaria
2026-02-20 20:55:58 +00:00
parent ee3b46e468
commit a0c65ca3d7
6 changed files with 278 additions and 192 deletions
@@ -61,6 +61,8 @@ class HomepageController
'top_movers' => $topMovers,
'top_static' => $aggregate['top_static'] ?? collect(),
'top_animated' => $aggregate['top_animated'] ?? collect(),
'bottom_static' => $aggregate['bottom_static'] ?? collect(),
'bottom_animated' => $aggregate['bottom_animated'] ?? collect(),
'top_unicode' => $aggregate['top_unicode'] ?? collect(),
];
}
@@ -0,0 +1,34 @@
<?php
namespace App\Models\Concerns;
trait BuildsEmoteAggregates
{
protected static function aggregateSummaryAndUsageByType(
$baseQuery,
string $usageCountColumn,
string $emoteIdColumn,
?string $userIdColumn = null
): array {
$summaryQuery = (clone $baseQuery)
->selectRaw("COALESCE(SUM({$usageCountColumn}), 0) as total_usage")
->selectRaw("COUNT(DISTINCT {$emoteIdColumn}) as unique_emotes");
if ($userIdColumn !== null) {
$summaryQuery->selectRaw("COUNT(DISTINCT {$userIdColumn}) as unique_users");
}
$summary = $summaryQuery->first();
$usageByType = (clone $baseQuery)
->select('emotes.type')
->selectRaw("COALESCE(SUM({$usageCountColumn}), 0) as total_usage")
->groupBy('emotes.type')
->pluck('total_usage', 'type');
return [
'summary' => $summary,
'usage_by_type' => $usageByType,
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Collection;
trait BuildsEmoteUsageLists
{
protected static function emotesByType(
$baseQuery,
string $type,
string $usageCountColumn,
string $emoteIdColumn,
string $direction = 'desc'
): Collection {
$query = (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'
);
if ($direction === 'asc') {
$query->orderBy('total_usage');
} else {
$query->orderByDesc('total_usage');
}
return $query
->limit(10)
->get();
}
}
+32 -46
View File
@@ -2,11 +2,15 @@
namespace App\Models;
use App\Models\Concerns\BuildsEmoteAggregates;
use App\Models\Concerns\BuildsEmoteUsageLists;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class EmoteGuildStat extends Model
{
use BuildsEmoteAggregates;
use BuildsEmoteUsageLists;
protected $fillable = [
'emote_id',
'guild_id',
@@ -27,24 +31,19 @@ class EmoteGuildStat extends Model
);
}
/**
* 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();
$aggregate = self::aggregateSummaryAndUsageByType(
$baseQuery,
'emote_guild_stats.usage_count',
'emote_guild_stats.emote_id'
);
$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');
$summary = $aggregate['summary'];
$usageByType = $aggregate['usage_by_type'];
$unicodeUsage = (int) EmoteLog::query()
->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id')
@@ -88,48 +87,35 @@ class EmoteGuildStat extends Model
'ANIMATED' => (int) ($usageByType->get('ANIMATED') ?? 0),
'UNICODE' => $unicodeUsage,
],
'top_static' => self::topEmotesByType(
'top_static' => self::emotesByType(
$baseQuery,
'STATIC',
'emote_guild_stats.usage_count',
'emote_guild_stats.emote_id'
'emote_guild_stats.emote_id',
'desc'
),
'top_animated' => self::topEmotesByType(
'top_animated' => self::emotesByType(
$baseQuery,
'ANIMATED',
'emote_guild_stats.usage_count',
'emote_guild_stats.emote_id'
'emote_guild_stats.emote_id',
'desc'
),
'bottom_static' => self::emotesByType(
$baseQuery,
'STATIC',
'emote_guild_stats.usage_count',
'emote_guild_stats.emote_id',
'asc'
),
'bottom_animated' => self::emotesByType(
$baseQuery,
'ANIMATED',
'emote_guild_stats.usage_count',
'emote_guild_stats.emote_id',
'asc'
),
'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();
}
}
+36 -49
View File
@@ -2,11 +2,15 @@
namespace App\Models;
use App\Models\Concerns\BuildsEmoteAggregates;
use App\Models\Concerns\BuildsEmoteUsageLists;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class UserGuildStat extends Model
{
use BuildsEmoteAggregates;
use BuildsEmoteUsageLists;
protected $fillable = [
'user_id',
'guild_id',
@@ -25,26 +29,21 @@ 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();
$aggregate = self::aggregateSummaryAndUsageByType(
$baseQuery,
'user_guild_stats.usage_count',
'user_guild_stats.emote_id',
'user_guild_stats.user_id'
);
$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');
$summary = $aggregate['summary'];
$usageByType = $aggregate['usage_by_type'];
return [
'total_usage' => (int) ($summary->total_usage ?? 0),
@@ -55,53 +54,41 @@ class UserGuildStat extends Model
'ANIMATED' => (int) ($usageByType->get('ANIMATED') ?? 0),
'UNICODE' => (int) ($usageByType->get('UNICODE') ?? 0),
],
'top_static' => self::topEmotesByType(
'top_static' => self::emotesByType(
$baseQuery,
'STATIC',
'user_guild_stats.usage_count',
'user_guild_stats.emote_id'
'user_guild_stats.emote_id',
'desc'
),
'top_animated' => self::topEmotesByType(
'top_animated' => self::emotesByType(
$baseQuery,
'ANIMATED',
'user_guild_stats.usage_count',
'user_guild_stats.emote_id'
'user_guild_stats.emote_id',
'desc'
),
'top_unicode' => self::topEmotesByType(
'bottom_static' => self::emotesByType(
$baseQuery,
'STATIC',
'user_guild_stats.usage_count',
'user_guild_stats.emote_id',
'asc'
),
'bottom_animated' => self::emotesByType(
$baseQuery,
'ANIMATED',
'user_guild_stats.usage_count',
'user_guild_stats.emote_id',
'asc'
),
'top_unicode' => self::emotesByType(
$baseQuery,
'UNICODE',
'user_guild_stats.usage_count',
'user_guild_stats.emote_id'
'user_guild_stats.emote_id',
'desc'
),
];
}
/**
* 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();
}
}
+103 -68
View File
@@ -35,6 +35,29 @@
'values' => data_get($stats, 'usage_over_time.values', []),
],
];
$customUsageTables = [
[
'title' => 'Top 10 Static Emotes',
'list' => $stats['top_static'] ?? collect(),
'empty' => 'No static emote usage found.',
],
[
'title' => 'Top 10 Animated Emotes',
'list' => $stats['top_animated'] ?? collect(),
'empty' => 'No animated emote usage found.',
],
[
'title' => 'Bottom 10 Static Emotes',
'list' => $stats['bottom_static'] ?? collect(),
'empty' => 'No static emote usage found.',
],
[
'title' => 'Bottom 10 Animated Emotes',
'list' => $stats['bottom_animated'] ?? collect(),
'empty' => 'No animated emote usage found.',
],
];
@endphp
<x-layouts.app>
@@ -69,36 +92,36 @@
@endif
</div>
<div class="mb-8 overflow-x-auto lg:overflow-visible">
<div class="flex gap-3 pb-1 min-w-max lg:min-w-0 lg:w-full lg:justify-between">
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<div class="mb-8">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Total Emoji Usage</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['total_usage'] ?? 0 }}</p>
<p class="mt-2 text-2xl font-semibold text-white break-words">{{ $stats['total_usage'] ?? 0 }}</p>
</div>
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Unique Emotes</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['unique_emotes'] ?? 0 }}</p>
<p class="mt-2 text-2xl font-semibold text-white break-words">{{ $stats['unique_emotes'] ?? 0 }}</p>
</div>
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Users Included</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['unique_users'] ?? 0 }}</p>
<p class="mt-2 text-2xl font-semibold text-white break-words">{{ $stats['unique_users'] ?? 0 }}</p>
</div>
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Static Usage</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['usage_by_type']['STATIC'] ?? 0 }}</p>
<p class="mt-2 text-2xl font-semibold text-white break-words">{{ $stats['usage_by_type']['STATIC'] ?? 0 }}</p>
</div>
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Animated Usage</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['usage_by_type']['ANIMATED'] ?? 0 }}</p>
<p class="mt-2 text-2xl font-semibold text-white break-words">{{ $stats['usage_by_type']['ANIMATED'] ?? 0 }}</p>
</div>
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Unicode Usage</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['usage_by_type']['UNICODE'] ?? 0 }}</p>
<p class="mt-2 text-2xl font-semibold text-white break-words">{{ $stats['usage_by_type']['UNICODE'] ?? 0 }}</p>
</div>
</div>
</div>
@@ -107,26 +130,29 @@
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Emote Usage Over Time (Last 30 Days)</h3>
<div class="p-6 border rounded-lg bg-[#2b2d31] border-[#3f4147] md:p-7">
<canvas id="emoji-usage-over-time-chart"></canvas>
<div class="p-4 overflow-hidden border rounded-lg bg-[#2b2d31] border-[#3f4147] md:p-5">
<div class="h-44 min-w-0 sm:h-56 md:h-64 lg:h-72">
<canvas id="emoji-usage-over-time-chart" class="w-full h-full"></canvas>
</div>
</div>
</div>
<div class="grid gap-8 lg:grid-cols-2">
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Top Movers (Last 7 Days vs Previous 7 Days)</h3>
<div class="overflow-hidden border rounded-lg bg-[#2b2d31] border-[#3f4147]">
@forelse (($stats['top_movers'] ?? collect()) as $emote)
<div class="flex items-center justify-between px-4 py-3.5 border-b border-[#3f4147] last:border-b-0">
<div class="flex items-center gap-3">
<div class="flex flex-col items-start gap-2 px-4 py-3.5 border-b border-[#3f4147] sm:flex-row sm:items-center sm:justify-between sm:gap-3 last:border-b-0">
<div class="flex items-center gap-3 min-w-0">
@if (($emote->type ?? null) === 'UNICODE')
<span class="font-medium text-[#dbdee1]">{{ $emote->emote_name }}</span>
<span class="font-medium truncate text-[#dbdee1]">{{ $emote->emote_name }}</span>
@else
<img src="{{ $emoteImageSrc($emote->image) }}" alt="{{ $emote->emote_name }}" class="object-cover w-9 h-9 border rounded border-[#3f4147]" />
<span class="font-medium text-[#dbdee1]">{{ $emote->emote_name }}</span>
<span class="font-medium truncate text-[#dbdee1]">{{ $emote->emote_name }}</span>
@endif
</div>
<div class="text-right">
<div class="text-left sm:text-right sm:shrink-0">
<p class="text-sm text-[#b5bac1]">{{ $emote->previous_count }} {{ $emote->current_count }}</p>
<p class="text-sm font-medium {{ $emote->delta >= 0 ? 'text-[#57f287]' : 'text-[#ed4245]' }}">
{{ $emote->delta >= 0 ? '+' : '' }}{{ $emote->delta }}
@@ -139,55 +165,17 @@
</div>
</div>
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Top 10 Static Emotes</h3>
<div class="overflow-hidden border rounded-lg bg-[#2b2d31] border-[#3f4147]">
@forelse (($stats['top_static'] ?? collect()) as $emote)
<div class="flex items-center justify-between px-4 py-3.5 border-b border-[#3f4147] last:border-b-0">
<div class="flex items-center gap-3">
<span class="w-8 text-sm font-semibold text-[#b5bac1]">#{{ $loop->iteration }}</span>
<img src="{{ $emoteImageSrc($emote->image) }}" alt="{{ $emote->emote_name }}" class="object-cover w-9 h-9 border rounded border-[#3f4147]" />
<span class="font-medium text-[#dbdee1]">{{ $emote->emote_name }}</span>
</div>
<span class="text-sm font-medium text-[#b5bac1]">{{ $emote->total_usage }}</span>
</div>
@empty
<p class="px-4 py-3 text-sm text-[#b5bac1]">No static emote usage found.</p>
@endforelse
</div>
</div>
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Top 10 Animated Emotes</h3>
<div class="overflow-hidden border rounded-lg bg-[#2b2d31] border-[#3f4147]">
@forelse (($stats['top_animated'] ?? collect()) as $emote)
<div class="flex items-center justify-between px-4 py-3.5 border-b border-[#3f4147] last:border-b-0">
<div class="flex items-center gap-3">
<span class="w-8 text-sm font-semibold text-[#b5bac1]">#{{ $loop->iteration }}</span>
<img src="{{ $emoteImageSrc($emote->image) }}" alt="{{ $emote->emote_name }}" class="object-cover w-9 h-9 border rounded border-[#3f4147]" />
<span class="font-medium text-[#dbdee1]">{{ $emote->emote_name }}</span>
</div>
<span class="text-sm font-medium text-[#b5bac1]">{{ $emote->total_usage }}</span>
</div>
@empty
<p class="px-4 py-3 text-sm text-[#b5bac1]">No animated emote usage found.</p>
@endforelse
</div>
</div>
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Top 10 Unicode Emotes</h3>
<div class="overflow-hidden border rounded-lg bg-[#2b2d31] border-[#3f4147]">
@forelse (($stats['top_unicode'] ?? collect()) as $emote)
<div class="flex items-center justify-between px-4 py-3.5 border-b border-[#3f4147] last:border-b-0">
<div class="flex items-center gap-3">
<div class="flex flex-col items-start gap-2 px-4 py-3.5 border-b border-[#3f4147] sm:flex-row sm:items-center sm:justify-between sm:gap-3 last:border-b-0">
<div class="flex items-center gap-3 min-w-0">
<span class="w-8 text-sm font-semibold text-[#b5bac1]">#{{ $loop->iteration }}</span>
<span class="font-medium text-[#dbdee1]">{{ $emote->emote_name }}</span>
<span class="font-medium truncate text-[#dbdee1]">{{ $emote->emote_name }}</span>
</div>
<span class="text-sm font-medium text-[#b5bac1]">{{ $emote->total_usage }}</span>
<span class="text-sm font-medium text-[#b5bac1] sm:shrink-0">{{ $emote->total_usage }}</span>
</div>
@empty
<p class="px-4 py-3 text-sm text-[#b5bac1]">No unicode emote usage found.</p>
@@ -196,6 +184,34 @@
</div>
</div>
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Custom Emote Usage (Top & Bottom)</h3>
<div class="grid gap-4 md:grid-cols-2">
@foreach ($customUsageTables as $table)
<div class="overflow-hidden border rounded-lg bg-[#2b2d31] border-[#3f4147]">
<div class="px-4 py-3 border-b border-[#3f4147]">
<h4 class="text-sm font-semibold text-white">{{ $table['title'] }}</h4>
</div>
@forelse ($table['list'] as $emote)
<div class="flex flex-col items-start gap-2 px-4 py-3.5 border-b border-[#3f4147] sm:flex-row sm:items-center sm:justify-between sm:gap-3 last:border-b-0">
<div class="flex items-center gap-3 min-w-0">
<span class="w-8 text-sm font-semibold text-[#b5bac1]">#{{ $loop->iteration }}</span>
<img src="{{ $emoteImageSrc($emote->image) }}" alt="{{ $emote->emote_name }}" class="object-cover w-9 h-9 border rounded border-[#3f4147]" />
<span class="font-medium truncate text-[#dbdee1]">{{ $emote->emote_name }}</span>
</div>
<span class="text-sm font-medium text-[#b5bac1] sm:shrink-0">{{ $emote->total_usage }}</span>
</div>
@empty
<p class="px-4 py-3 text-sm text-[#b5bac1]">{{ $table['empty'] }}</p>
@endforelse
</div>
@endforeach
</div>
</div>
</div>
<script id="homepage-chart-json" type="application/json">@json($chartPayload)</script>
@once
@@ -237,6 +253,7 @@
const usageOverTimeCanvas = document.getElementById('emoji-usage-over-time-chart');
const usageOverTime = payload.usage_over_time || { labels: [], values: [] };
const isMobileViewport = window.matchMedia('(max-width: 639px)').matches;
if (usageOverTimeCanvas) {
hermesDestroyChart('usageOverTime');
@@ -250,15 +267,23 @@
tension: 0.3,
fill: false,
borderColor: '#5865f2',
borderWidth: 2,
pointBackgroundColor: '#5865f2',
pointBorderColor: '#5865f2'
pointBorderColor: '#5865f2',
pointRadius: isMobileViewport ? 0 : 2,
pointHoverRadius: isMobileViewport ? 2 : 4
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: !isMobileViewport,
labels: {
color: '#dbdee1'
}
@@ -267,17 +292,23 @@
scales: {
x: {
ticks: {
color: '#b5bac1'
color: '#b5bac1',
autoSkip: true,
maxTicksLimit: isMobileViewport ? 4 : 8,
maxRotation: 0,
minRotation: 0
},
grid: {
color: '#3f4147'
color: '#3f4147',
display: !isMobileViewport
}
},
y: {
beginAtZero: true,
ticks: {
precision: 0,
color: '#b5bac1'
color: '#b5bac1',
maxTicksLimit: isMobileViewport ? 5 : 8
},
grid: {
color: '#3f4147'
@@ -291,6 +322,10 @@
document.addEventListener('DOMContentLoaded', function () {
hermesRenderOverTimeChart();
window.addEventListener('resize', function () {
hermesRenderOverTimeChart();
});
});
</script>
@endonce