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
3 changes: 3 additions & 0 deletions sql/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
\i contests/user_contest_feedback.sql
\i contests/contest_functions.sql

-- Leaderboard functions
\i leaderboard/leaderboard_functions.sql

-- Grant necessary permissions
GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
GRANT ALL ON ALL TABLES IN SCHEMA public TO anon, authenticated, service_role;
Expand Down
50 changes: 50 additions & 0 deletions sql/leaderboard/leaderboard_functions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-- Create a function to get leaderboard data
CREATE OR REPLACE FUNCTION get_leaderboard() RETURNS TABLE (
user_id UUID,
username TEXT,
avatar_url TEXT,
github_url TEXT,
problems_solved BIGINT,
earliest_solves_sum BIGINT,
rank BIGINT
) AS $$ WITH user_stats AS (
SELECT u.id AS user_id,
u.raw_user_meta_data->>'user_name' AS username,
u.raw_user_meta_data->>'avatar_url' AS avatar_url,
CASE
WHEN u.raw_user_meta_data->>'html_url' IS NOT NULL THEN u.raw_user_meta_data->>'html_url'
ELSE 'https://github.com/' || (u.raw_user_meta_data->>'user_name')
END AS github_url,
COUNT(usp.problem_id)::BIGINT AS problems_solved,
COALESCE(
SUM(
EXTRACT(
EPOCH
FROM usp.solved_at
)::BIGINT
),
0
) AS earliest_solves_sum
FROM auth.users u
LEFT JOIN user_solved_problems usp ON u.id = usp.user_id
GROUP BY u.id,
u.raw_user_meta_data
HAVING COUNT(usp.problem_id) > 0
)
SELECT user_stats.user_id,
user_stats.username,
user_stats.avatar_url,
user_stats.github_url,
user_stats.problems_solved,
user_stats.earliest_solves_sum,
ROW_NUMBER() OVER (
ORDER BY user_stats.problems_solved DESC,
user_stats.earliest_solves_sum ASC
)::BIGINT AS rank
FROM user_stats
ORDER BY user_stats.problems_solved DESC,
user_stats.earliest_solves_sum ASC;
$$ LANGUAGE SQL SECURITY DEFINER;
-- Grant permissions
GRANT EXECUTE ON FUNCTION get_leaderboard() TO authenticated,
anon;
18 changes: 18 additions & 0 deletions src/lib/components/Header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,17 @@ $: if ($page) {
>Contests</a
>
</li>
<li
class="relative {$page.url.pathname === '/leaderboard'
? "after:absolute after:-bottom-2 after:left-0 after:h-0.5 after:w-full after:rounded-sm after:bg-[var(--color-accent)] after:content-['']"
: ''}"
>
<a
href="/leaderboard"
class="block py-2 text-base font-semibold text-[var(--color-heading)] no-underline transition-colors duration-200 hover:text-[var(--color-accent)]"
>Leaderboard</a
>
</li>
<li
class="relative {$page.url.pathname === '/about'
? "after:absolute after:-bottom-2 after:left-0 after:h-0.5 after:w-full after:rounded-sm after:bg-[var(--color-accent)] after:content-['']"
Expand Down Expand Up @@ -266,6 +277,13 @@ $: if ($page) {
>Contests</a
>
</li>
<li>
<a
href="/leaderboard"
class="block py-2 text-base font-semibold text-[var(--color-heading)] no-underline transition-colors duration-200 hover:text-[var(--color-accent)] {$page.url.pathname === '/leaderboard' ? 'text-[var(--color-accent)]' : ''}"
>Leaderboard</a
>
</li>
<li>
<a
href="/about"
Expand Down
144 changes: 144 additions & 0 deletions src/lib/components/LeaderboardTable.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<script lang="ts">
import type { LeaderboardEntry } from '$lib/services/leaderboard';

// Props
export let leaderboardEntries: LeaderboardEntry[] = [];

// Format large numbers with commas
function formatNumber(num: number): string {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
</script>

<div class="mt-4 w-full">
<div class="table-wrapper rounded-lg bg-[var(--color-secondary)] shadow-sm">
<table
class="w-full min-w-[900px] table-fixed border-collapse overflow-hidden bg-[var(--color-secondary)]"
>
<thead>
<tr>
<th
class="sticky top-0 z-10 w-[10%] bg-[var(--color-tertiary)] p-3 text-center font-bold"
>
Rank
</th>
<th class="sticky top-0 z-10 w-[60%] bg-[var(--color-tertiary)] p-3 text-left font-bold">
User
</th>
<th
class="sticky top-0 z-10 w-[30%] bg-[var(--color-tertiary)] p-3 text-center font-bold"
>
Solves
</th>
</tr>
</thead>
<tbody>
{#each leaderboardEntries as entry}
<tr
class="relative border-b border-[var(--color-border)] transition-colors duration-200 last:border-b-0 hover:bg-black/5"
>
<td class="p-3 text-center">
<!-- Rank with special styling for top 3 -->
{#if entry.rank === 1}
<span
class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-legendary-grandmaster)] font-bold text-white"
>
{entry.rank}
</span>
{:else if entry.rank === 2}
<span
class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-specialist)] font-bold text-white"
>
{entry.rank}
</span>
{:else if entry.rank === 3}
<span
class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-pupil)] font-bold text-white"
>
{entry.rank}
</span>
{:else}
<span class="font-medium text-[var(--color-text-muted)]">{entry.rank}</span>
{/if}
</td>
<td class="p-3">
<div class="flex items-center gap-3">
{#if entry.avatarUrl}
<img
src={entry.avatarUrl}
alt={entry.username}
class="h-10 w-10 rounded-full border border-[var(--color-border)]"
/>
{:else}
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-tertiary)] text-sm font-medium"
>
{entry.username.substring(0, 2).toUpperCase()}
</div>
{/if}
<a
href={entry.githubUrl}
target="_blank"
rel="noopener noreferrer"
class="text-[var(--color-username)] hover:text-[color-mix(in_oklab,var(--color-username)_80%,white)] hover:underline"
title={"@" + entry.username}
>
@{entry.username}
</a>
</div>
</td>
<td class="p-3 text-center font-medium">
{formatNumber(entry.problemsSolved)}
</td>
</tr>
{/each}

{#if leaderboardEntries.length === 0}
<tr>
<td colspan="3" class="p-8 text-center text-[var(--color-text-muted)]">
No data available. Start solving problems to appear on the leaderboard!
</td>
</tr>
{/if}
</tbody>
</table>
</div>
</div>

<style>
/* Ensure table is responsive and centered */
@media (max-width: 768px) {
div {
margin-left: auto;
margin-right: auto;
}
}

/* Remove margin between sidebar and table */
@media (min-width: 768px) {
.table-wrapper {
margin-left: 0;
}
}

/* Ensure table fits within container */
.w-full {
max-width: 100%;
}

/* Ensure proper table scrolling */
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
}

/* Ensure username is always purple */
a[href*='github.com'] {
color: var(--color-username) !important;
}

a[href*='github.com']:hover {
color: color-mix(in oklab, var(--color-username) 80%, white) !important;
}
</style>
60 changes: 60 additions & 0 deletions src/lib/services/leaderboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Service for leaderboard operations
*/
import { supabase } from './database';

/**
* Leaderboard entry interface
*/
export type LeaderboardEntry = {
userId: string;
username: string;
avatarUrl: string;
githubUrl: string;
problemsSolved: number;
earliestSolvesSum: number;
rank: number; // Now comes directly from the database
};

/**
* Database record type from Supabase
*/
export type LeaderboardRecord = {
user_id: string;
username: string;
avatar_url: string;
github_url: string;
problems_solved: number;
earliest_solves_sum: number;
rank: number;
};

/**
* Fetches leaderboard data from the database
* @returns Array of leaderboard entries
*/
export async function fetchLeaderboard(): Promise<LeaderboardEntry[]> {
try {
// Fetch leaderboard data using the get_leaderboard function
const { data, error } = await supabase.rpc('get_leaderboard');

if (error) {
console.error('Error fetching leaderboard:', error);
return [];
}

// Transform database records to LeaderboardEntry type
return (data as LeaderboardRecord[]).map((record) => ({
userId: record.user_id,
username: record.username,
avatarUrl: record.avatar_url,
githubUrl: record.github_url,
problemsSolved: record.problems_solved,
earliestSolvesSum: record.earliest_solves_sum,
rank: record.rank
}));
} catch (err) {
console.error('Failed to fetch leaderboard:', err);
return [];
}
}
95 changes: 95 additions & 0 deletions src/routes/leaderboard/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { fetchLeaderboard } from '$lib/services/leaderboard';
import type { LeaderboardEntry } from '$lib/services/leaderboard';
import LeaderboardTable from '$lib/components/LeaderboardTable.svelte';

let leaderboardEntries: LeaderboardEntry[] = [];
let loading: boolean = false;
let error: string | null = null;

// Load leaderboard data
async function loadLeaderboard(): Promise<void> {
if (!browser) return;

loading = true;
error = null;

try {
leaderboardEntries = await fetchLeaderboard();
} catch (err) {
console.error('Error loading leaderboard:', err);
error = 'Failed to load leaderboard data. Please try again later.';
} finally {
loading = false;
}
}

// Load data on mount
onMount(() => {
loadLeaderboard();
});
</script>

<svelte:head>
<title>AlgoHub | Leaderboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
</svelte:head>

<div class="mx-auto w-full max-w-[1200px] px-0">
{#if loading}
<div class="flex h-[calc(100vh-4rem)] items-center justify-center py-2 text-center">
<div>
<svg
class="mx-auto h-10 w-10 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="var(--color-primary)"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="mt-2 text-gray-600">Loading leaderboard...</p>
</div>
</div>
{:else if error}
<div class="py-2 text-center text-red-500">
<p>{error}</p>
<button
class="hover:bg-opacity-90 mt-2 rounded bg-[var(--color-primary)] px-4 py-2 text-white transition-colors"
on:click={() => loadLeaderboard()}>Try Again</button
>
</div>
{:else}
<div class="relative min-h-[calc(100vh-2rem)]">
<!-- Main content -->
<div class="flex w-full flex-1">
<div class="w-full min-w-0 px-0 py-2 pb-6">
<div class="leaderboard-container w-full">
<LeaderboardTable leaderboardEntries={leaderboardEntries} />
</div>
</div>
</div>
</div>
{/if}
</div>

<style>
@media (max-width: 767px) {
:global(body) {
overflow-x: hidden;
}
}

/* Remove excess margin from table container */
.leaderboard-container {
width: 100%;
margin: 0;
}
</style>