mirror of
https://github.com/the-programmers-hangout/Hermes.git
synced 2026-09-04 01:06:00 +02:00
add website to view stats
This commit is contained in:
+2
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EmoteGuildStat;
|
||||
use App\Models\EmoteLog;
|
||||
use App\Models\UserGuildStat;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HomepageController
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$rawUserIdFilter = trim((string) $request->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user