Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 87 additions & 168 deletions app/Http/Controllers/LeaderboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,182 +12,23 @@ class LeaderboardController extends Controller
{
public function index(Request $request): Response
{
$tab = $request->input('tab');
if (! in_array($tab, ['students', 'contributors'], true)) {
$tab = 'students';
}
$tab = in_array($request->input('tab'), ['students', 'contributors'], true)
? $request->input('tab')
: 'students';

$timeframe = $request->input('timeframe');
if (! in_array($timeframe, ['this_week', 'this_month', 'all_time'], true)) {
$timeframe = 'this_week';
}
$timeframe = in_array($request->input('timeframe'), ['this_week', 'this_month', 'all_time'], true)
? $request->input('timeframe')
: 'this_week';

$dateFilter = match ($timeframe) {
'this_week' => Carbon::now()->startOfWeek()->toDateString(),
'this_month' => Carbon::now()->startOfMonth()->toDateString(),
'all_time' => null,
};

$currentUser = $request->user();
$currentUserRank = null;

if ($tab === 'contributors') {
$query = User::query()
->select([
'users.id',
'users.name',
'users.username',
'users.image_path',
'users.institution',
'users.is_verified',
'users.title',
])
->where(function ($q) {
$q->whereNull('banned_until')->orWhere('banned_until', '<=', now());
})
->whereHas('resources', function ($q) use ($dateFilter) {
$q->when($dateFilter, fn ($sq) => $sq->where('created_at', '>=', $dateFilter));
})
->withCount(['resources' => function ($q) use ($dateFilter) {
$q->when($dateFilter, fn ($sq) => $sq->where('created_at', '>=', $dateFilter));
}])
->orderByDesc('resources_count')
->latest('users.id');

$leaderboard = $query->take(50)->get()->map(function ($u, $index) {
return [
'rank' => $index + 1,
'id' => $u->id,
'name' => $u->name,
'username' => $u->username,
'image_url' => $u->image_url,
'institution' => $u->institution,
'title' => $u->title,
'is_verified' => $u->is_verified,
'score' => (int) $u->resources_count,
];
});

if ($currentUser) {
$myScore = (int) $currentUser->resources()
->when($dateFilter, fn ($sq) => $sq->where('created_at', '>=', $dateFilter))
->count();

if ($myScore > 0) {
// Check if user is in the fetched top 50
$found = $leaderboard->firstWhere('id', $currentUser->id);
if ($found) {
$currentUserRank = [
'rank' => $found['rank'],
'score' => $myScore,
];
} else {
$higherCount = User::query()
->where(function ($q) {
$q->whereNull('banned_until')->orWhere('banned_until', '<=', now());
})
->whereHas('resources', function ($q) use ($dateFilter) {
$q->when($dateFilter, fn ($sq) => $sq->where('created_at', '>=', $dateFilter));
})
->withCount(['resources' => function ($q) use ($dateFilter) {
$q->when($dateFilter, fn ($sq) => $sq->where('created_at', '>=', $dateFilter));
}])
->having('resources_count', '>', $myScore)
->count();

$currentUserRank = [
'rank' => $higherCount + 1,
'score' => $myScore,
];
}
}
}
} else {
// Students: Ranked by total study time
$query = User::query()
->select([
'users.id',
'users.name',
'users.username',
'users.image_path',
'users.institution',
'users.is_verified',
'users.title',
'users.curriculum',
'users.group',
])
->where(function ($q) {
$q->whereNull('banned_until')->orWhere('banned_until', '<=', now());
})
->where(function ($q) {
$q->whereNull('activity_privacy')->orWhere('activity_privacy', '!=', 'private');
})
->whereHas('dailyStudyLogs', function ($q) use ($dateFilter) {
$q->when($dateFilter, fn ($sq) => $sq->where('study_date', '>=', $dateFilter))
->where('total_seconds', '>', 0);
})
->withSum(['dailyStudyLogs as total_study_seconds' => function ($q) use ($dateFilter) {
$q->when($dateFilter, fn ($sq) => $sq->where('study_date', '>=', $dateFilter));
}], 'total_seconds')
->withCount('nodeCompletions')
->orderByDesc('total_study_seconds')
->latest('users.id');

$leaderboard = $query->take(50)->get()->map(function ($u, $index) {
return [
'rank' => $index + 1,
'id' => $u->id,
'name' => $u->name,
'username' => $u->username,
'image_url' => $u->image_url,
'institution' => $u->institution,
'title' => $u->title,
'curriculum' => $u->curriculum,
'group' => $u->group,
'is_verified' => $u->is_verified,
'score' => (int) $u->total_study_seconds,
'completed_chapters_count' => (int) $u->node_completions_count,
];
});

if ($currentUser) {
$myScore = (int) $currentUser->dailyStudyLogs()
->when($dateFilter, fn ($sq) => $sq->where('study_date', '>=', $dateFilter))
->sum('total_seconds');

if ($myScore > 0) {
$found = $leaderboard->firstWhere('id', $currentUser->id);
if ($found) {
$currentUserRank = [
'rank' => $found['rank'],
'score' => $myScore,
];
} else {
$higherCount = User::query()
->where(function ($q) {
$q->whereNull('banned_until')->orWhere('banned_until', '<=', now());
})
->where(function ($q) {
$q->whereNull('activity_privacy')->orWhere('activity_privacy', '!=', 'private');
})
->whereHas('dailyStudyLogs', function ($q) use ($dateFilter) {
$q->when($dateFilter, fn ($sq) => $sq->where('study_date', '>=', $dateFilter))
->where('total_seconds', '>', 0);
})
->withSum(['dailyStudyLogs as total_study_seconds' => function ($q) use ($dateFilter) {
$q->when($dateFilter, fn ($sq) => $sq->where('study_date', '>=', $dateFilter));
}], 'total_seconds')
->having('total_study_seconds', '>', $myScore)
->count();

$currentUserRank = [
'rank' => $higherCount + 1,
'score' => $myScore,
];
}
}
}
}
[$leaderboard, $currentUserRank] = $tab === 'contributors'
? $this->getContributors($request->user(), $dateFilter)
: $this->getStudents($request->user(), $dateFilter);

return Inertia::render('Leaderboard/Index', [
'tab' => $tab,
Expand All @@ -196,4 +37,82 @@ public function index(Request $request): Response
'currentUserRank' => $currentUserRank,
]);
}

private function getContributors(?User $user, ?string $dateFilter): array
{
$users = User::query()
->select(['id', 'name', 'username', 'image_path', 'institution', 'is_verified', 'title'])
->where(fn ($q) => $q->whereNull('banned_until')->orWhere('banned_until', '<=', now()))
->whereHas('resources', fn ($q) => $q->when($dateFilter, fn ($sq) => $sq->where('created_at', '>=', $dateFilter)))
->withCount(['resources' => fn ($q) => $q->when($dateFilter, fn ($sq) => $sq->where('created_at', '>=', $dateFilter))])
->orderByDesc('resources_count')
->latest('id')
->take(50)
->get();

$myScore = $user ? (int) $user->resources()->when($dateFilter, fn ($q) => $q->where('created_at', '>=', $dateFilter))->count() : 0;

return $this->formatLeaderboard($users, 'resources_count', $user, $myScore);
}

private function getStudents(?User $user, ?string $dateFilter): array
{
$users = User::query()
->select(['id', 'name', 'username', 'image_path', 'institution', 'is_verified', 'title', 'curriculum', 'group'])
->where(fn ($q) => $q->whereNull('banned_until')->orWhere('banned_until', '<=', now()))
->where(fn ($q) => $q->whereNull('activity_privacy')->orWhere('activity_privacy', '!=', 'private'))
->whereHas('dailyStudyLogs', fn ($q) => $q->when($dateFilter, fn ($sq) => $sq->where('study_date', '>=', $dateFilter))->where('total_seconds', '>', 0))
->withSum(['dailyStudyLogs as total_study_seconds' => fn ($q) => $q->when($dateFilter, fn ($sq) => $sq->where('study_date', '>=', $dateFilter))], 'total_seconds')
->orderByDesc('total_study_seconds')
->latest('id')
->take(50)
->get();

$myScore = $user ? (int) $user->dailyStudyLogs()->when($dateFilter, fn ($q) => $q->where('study_date', '>=', $dateFilter))->sum('total_seconds') : 0;

return $this->formatLeaderboard($users, 'total_study_seconds', $user, $myScore);
}

private function formatLeaderboard($users, string $scoreKey, ?User $currentUser, int $myScore): array
{
$leaderboard = [];
$currentRank = 1;
$prevScore = null;

foreach ($users as $index => $u) {
$score = (int) $u->{$scoreKey};
if ($prevScore !== null && $score === $prevScore) {
$rank = $currentRank;
} else {
$currentRank = $index + 1;
$rank = $currentRank;
$prevScore = $score;
}

$leaderboard[] = [
'rank' => $rank,
'id' => $u->id,
'name' => $u->name,
'username' => $u->username,
'image_url' => $u->image_url,
'institution' => $u->institution,
'title' => $u->title,
'curriculum' => $u->curriculum ?? null,
'group' => $u->group ?? null,
'is_verified' => (bool) $u->is_verified,
'score' => $score,
];
}

$currentUserRank = null;
if ($currentUser && $myScore > 0) {
$found = collect($leaderboard)->firstWhere('id', $currentUser->id);
$currentUserRank = [
'rank' => $found ? $found['rank'] : '50+',
'score' => $myScore,
];
}

return [$leaderboard, $currentUserRank];
}
}
16 changes: 11 additions & 5 deletions resources/js/pages/Leaderboard/Index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface LeaderboardUser {
}

export interface CurrentUserRank {
rank: number;
rank: number | string;
score: number;
}

Expand Down Expand Up @@ -258,7 +258,7 @@ const podiumUsers = computed(() => {
</div>
<span
v-if="currentUserRank"
class="absolute -right-1 -bottom-1 flex h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-[10px] font-black text-white shadow-xs"
class="absolute -right-1 -bottom-1 flex min-h-5 min-w-5 items-center justify-center rounded-full bg-indigo-600 px-1 text-[10px] font-black text-white shadow-xs"
>
#{{ currentUserRank.rank }}
</span>
Expand Down Expand Up @@ -348,7 +348,7 @@ const podiumUsers = computed(() => {
<!-- Place Badge -->
<div class="mb-2">
<span
v-if="place === 1"
v-if="pUser.rank === 1"
class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-0.5 text-[11px] font-black text-amber-800 shadow-2xs dark:bg-amber-950/60 dark:text-amber-300"
>
<Crown
Expand All @@ -357,21 +357,27 @@ const podiumUsers = computed(() => {
<span>১ম স্থান</span>
</span>
<span
v-else-if="place === 2"
v-else-if="pUser.rank === 2"
class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-0.5 text-[11px] font-bold text-slate-700 dark:bg-gray-800 dark:text-gray-300"
>
<Medal class="h-3.5 w-3.5 text-slate-400" />
<span>২য় স্থান</span>
</span>
<span
v-else
v-else-if="pUser.rank === 3"
class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-0.5 text-[11px] font-bold text-amber-900 dark:bg-amber-950/40 dark:text-amber-400"
>
<Award
class="h-3.5 w-3.5 text-amber-600 dark:text-amber-500"
/>
<span>৩য় স্থান</span>
</span>
<span
v-else
class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-0.5 text-[11px] font-bold text-slate-700 dark:bg-gray-800 dark:text-gray-300"
>
<span>#{{ pUser.rank }}</span>
</span>
</div>

<!-- Podium User Card -->
Expand Down
8 changes: 5 additions & 3 deletions resources/js/pages/Tracker/Index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ function handleAuthRequired(

<div class="mx-auto max-w-4xl px-4 py-6 sm:px-6 sm:py-8">
<!-- Page Header -->
<div class="mb-6 flex items-center justify-between gap-3">
<div
class="mb-6 flex flex-col gap-3.5 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex items-center gap-2.5 sm:gap-3">
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-slate-900 text-white shadow-2xs sm:h-10 sm:w-10 dark:bg-white dark:text-slate-900"
Expand All @@ -69,7 +71,7 @@ function handleAuthRequired(
</div>

<!-- Header Actions: Leaderboard + User Profile -->
<div class="flex shrink-0 items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<Link
href="/leaderboard"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-xl border border-amber-200/80 bg-amber-50/70 px-2.5 py-1.5 text-xs font-bold text-amber-800 shadow-2xs transition hover:bg-amber-100 sm:px-3 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300 dark:hover:bg-amber-900/50"
Expand All @@ -84,7 +86,7 @@ function handleAuthRequired(
<Link
v-if="isAuthenticated && user"
:href="`/u/${user.username}`"
class="inline-flex items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
class="inline-flex items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-slate-700 transition hover:bg-slate-50 sm:px-3 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
<UserIcon
class="h-3.5 w-3.5 text-slate-500 dark:text-gray-400"
Expand Down
Loading