Skip to content

Commit 94ad852

Browse files
authored
Merge pull request #26 from RoBorregos/leaderboard-backend
Leaderboard backend and frontend
2 parents 8b89e6a + 161b733 commit 94ad852

File tree

10 files changed

+598
-213
lines changed

10 files changed

+598
-213
lines changed

package-lock.json

Lines changed: 318 additions & 176 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"prettier-plugin-tailwindcss": "^0.6.5",
5757
"prisma": "^5.14.0",
5858
"tailwindcss": "^3.4.3",
59+
"ts-node": "^10.9.2",
5960
"typescript": "^5.5.3"
6061
},
6162
"ct3aMetadata": {

prisma/schema.prisma

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,27 @@
1-
// This is your Prisma schema file,
2-
// learn more about it in the docs: https://pris.ly/d/prisma-schema
3-
41
generator client {
52
provider = "prisma-client-js"
63
}
74

85
datasource db {
96
provider = "cockroachdb"
10-
// NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below
11-
// Further reading:
12-
// https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
13-
// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
147
url = env("DATABASE_URL")
158
}
169

17-
// Necessary for Next auth
1810
model Account {
1911
id String @id @default(cuid())
2012
userId String
2113
type String
2214
provider String
2315
providerAccountId String
24-
refresh_token String? // @db.Text
25-
access_token String? // @db.Text
16+
refresh_token String?
17+
access_token String?
2618
expires_at Int?
2719
token_type String?
2820
scope String?
29-
id_token String? // @db.Text
21+
id_token String?
3022
session_state String?
31-
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
3223
refresh_token_expires_in Int?
24+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
3325
3426
@@unique([provider, providerAccountId])
3527
}
@@ -76,7 +68,6 @@ model Problem {
7668
id String @id @default(cuid())
7769
name String
7870
level String
79-
status String
8071
leetcodeUrl String
8172
weekId String
8273
week Week @relation(fields: [weekId], references: [id], onDelete: Cascade)
Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,158 @@
1+
"use client";
2+
import { useState } from "react";
3+
import { api } from "~/trpc/react";
4+
import { useSession } from "next-auth/react";
5+
type Week = {
6+
id?: number | string;
7+
number?: number;
8+
title?: string;
9+
};
110

11+
type LeaderboardEntry = {
12+
username: string;
13+
completedWarmup: number;
14+
completedMedium: number;
15+
completedHarder: number;
16+
completedInsane: number;
17+
total: number;
18+
};
219

320
const Leaderboard = () => {
21+
// Get current username
22+
const { data: session } = useSession();
23+
const currentUsername = session?.user?.name ?? "";
24+
25+
const [selected, setSelected] = useState("all");
26+
27+
// Fetch weeks for the slider
28+
const { data: weeksData, isLoading: weeksLoading } =
29+
api.week.getWeeks.useQuery();
30+
31+
// Fetch leaderboard data
32+
const {
33+
data: leaderboardData,
34+
isLoading: leaderboardLoading,
35+
error: leaderboardError,
36+
} = selected === "all"
37+
? api.leaderboard.getAll.useQuery()
38+
: api.leaderboard.getByWeek.useQuery(selected);
39+
40+
if (weeksLoading || leaderboardLoading) {
441
return (
5-
<div>
6-
Hello
42+
<div className="mx-auto mt-12 max-w-3xl">
43+
<h1 className="mb-8 text-center text-3xl font-extrabold tracking-tight text-white drop-shadow">
44+
Leaderboard
45+
</h1>
46+
<div className="mb-6 flex justify-center">
47+
<div className="inline-flex rounded-full bg-neutral-800 p-1">
48+
<span className="animate-pulse rounded-full bg-neutral-700 px-5 py-2 font-semibold text-gray-100">
49+
Loading...
50+
</span>
51+
</div>
52+
</div>
53+
<div className="mb-2 flex w-full flex-row justify-between rounded-t-2xl bg-neutral-800 px-8 py-3 text-lg font-semibold text-white">
54+
<span className="w-1/4 text-left">User</span>
55+
<span className="w-1/8 text-center">Warmup</span>
56+
<span className="w-1/8 text-center">Medium</span>
57+
<span className="w-1/8 text-center">Harder</span>
58+
<span className="w-1/8 text-center">Insane</span>
59+
<span className="w-1/8 text-center">Total</span>
60+
</div>
61+
</div>
62+
);
63+
}
64+
65+
if (leaderboardError) return <div>Error loading leaderboard</div>;
66+
67+
const weekOptions = [
68+
{ id: "all", label: "Global" },
69+
...(weeksData
70+
? weeksData.map((w: Week) => ({
71+
id: (w.id ?? w.number ?? "").toString(),
72+
label: w.title ?? `Week ${w.number}`,
73+
}))
74+
: []),
75+
];
76+
77+
const sorted = [...((leaderboardData as LeaderboardEntry[]) ?? [])].sort(
78+
(a, b) => b.total - a.total,
79+
);
80+
81+
let lastTotal: number | null = null;
82+
let lastPlace = 0;
83+
84+
const sortedWithPlace = sorted.map((row: LeaderboardEntry, i) => {
85+
if (row.total !== lastTotal) {
86+
lastPlace = i + 1;
87+
lastTotal = row.total;
88+
}
89+
return { ...row, place: lastPlace };
90+
});
91+
92+
return (
93+
<div className="mx-auto mt-12 max-w-3xl">
94+
<h1 className="mb-8 text-center text-3xl font-extrabold tracking-tight text-white drop-shadow">
95+
Leaderboard
96+
</h1>
97+
<div className="mb-6 flex justify-center">
98+
<div className="inline-flex rounded-full bg-neutral-800 p-1">
99+
{weekOptions.map((w) => (
100+
<button
101+
key={w.id}
102+
onClick={() => setSelected(w.id)}
103+
className={`rounded-full px-5 py-2 font-semibold transition-all ${
104+
selected === w.id
105+
? "bg-white text-neutral-900 shadow"
106+
: "text-white hover:bg-neutral-700"
107+
}`}
108+
>
109+
{w.label}
110+
</button>
111+
))}
7112
</div>
8-
)
9-
}
113+
</div>
114+
<div className="mb-2 flex w-full flex-row justify-between rounded-t-2xl bg-neutral-800 px-8 py-3 text-lg font-semibold text-white">
115+
<span className="w-1/4 text-left">User</span>
116+
<span className="w-1/8 text-center">Warmup</span>
117+
<span className="w-1/8 text-center">Medium</span>
118+
<span className="w-1/8 text-center">Harder</span>
119+
<span className="w-1/8 text-center">Insane</span>
120+
<span className="w-1/8 text-center">Total</span>
121+
</div>
122+
<ol className="flex flex-col gap-4">
123+
{sortedWithPlace.map((row, i) => (
124+
<li
125+
key={i}
126+
className={`flex w-full flex-row items-center justify-between rounded-2xl px-8 py-4 text-white shadow-lg transition-all ${row.place !== 1 && row.username !== currentUsername ? "bg-neutral-700" : ""} ${row.place === 1 ? "scale-[1.03] bg-gray-600 font-bold" : ""} ${row.username === currentUsername ? "bg-green-600 font-bold" : ""} `}
127+
>
128+
<div className="flex w-1/4 items-center gap-3">
129+
<span className="w-8 text-center text-xl font-semibold text-gray-400">
130+
{row.place}
131+
</span>
132+
<span className="break-words text-base font-medium leading-tight">
133+
{row.username}
134+
</span>
135+
</div>
136+
<span className="w-1/8 text-center text-lg font-bold">
137+
{row.completedWarmup}
138+
</span>
139+
<span className="w-1/8 text-center text-lg font-bold">
140+
{row.completedMedium}
141+
</span>
142+
<span className="w-1/8 text-center text-lg font-bold">
143+
{row.completedHarder}
144+
</span>
145+
<span className="w-1/8 text-center text-lg font-bold">
146+
{row.completedInsane}
147+
</span>
148+
<span className="w-1/8 text-center text-lg font-bold">
149+
{row.total}
150+
</span>
151+
</li>
152+
))}
153+
</ol>
154+
</div>
155+
);
156+
};
10157

11-
export default Leaderboard;
158+
export default Leaderboard;

src/app/_components/week/weekInfo.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { api } from "~/trpc/server";
22
import Title from "../title";
33
import Subtitle from "../subtitle";
4+
import { auth } from "~/server/auth";
45

56
const WeekInfo = async ({ id }: { id: number }) => {
67
const week = await api.week.getWeek(id);
8+
const session = await auth();
9+
const userId = session?.user?.id;
710

811
return (
912
<div>
@@ -71,7 +74,11 @@ const WeekInfo = async ({ id }: { id: number }) => {
7174
</td>
7275
<td>{problem.level}</td>
7376
<td>{problem.solvedBy?.length ?? 0}</td>
74-
<td>{problem.status}</td>
77+
<td>
78+
{userId && problem.solvedBy?.some((u) => u.id === userId)
79+
? "Solved"
80+
: "Unsolved"}
81+
</td>
7582
</tr>
7683
))}
7784
</tbody>

src/app/layout.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import "~/styles/globals.css";
22

33
import { GeistSans } from "geist/font/sans";
44
import { type Metadata } from "next";
5+
import { SessionProvider } from "next-auth/react";
56

67
import { TRPCReactProvider } from "~/trpc/react";
78
import Navbar from "./_components/nav/navbar";
@@ -20,8 +21,8 @@ export default function RootLayout({
2021
<html lang="en" className={`${GeistSans.variable}`}>
2122
<body className="bg-primary min-h-screen">
2223
<Navbar />
23-
<div className="px-20 py-10 h-screen">
24-
<TRPCReactProvider>{children}</TRPCReactProvider>
24+
<div className="h-auto px-20 py-10 ">
25+
<SessionProvider><TRPCReactProvider>{children}</TRPCReactProvider></SessionProvider>
2526
</div>
2627
<Footer />
2728
</body>

src/server/api/root.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { leetcodeRouter } from "~/server/api/routers/leetcode";
22
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
33
import { weekRouter } from "./routers/week";
44
import { problemRouter } from "./routers/problem";
5+
import { leaderboardRouter } from "./routers/leaderboard";
56

67
/**
78
* This is the primary router for your server.
@@ -12,6 +13,7 @@ export const appRouter = createTRPCRouter({
1213
leetcode: leetcodeRouter,
1314
week: weekRouter,
1415
problem : problemRouter,
16+
leaderboard: leaderboardRouter,
1517
});
1618

1719
// export type definition of API
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { z } from "zod";
2+
import { createTRPCRouter, publicProcedure } from "../trpc";
3+
4+
// Map problem levels to leaderboard columns
5+
const levelMap = {
6+
warmup: "warmup",
7+
medium: "medium",
8+
harder: "harder",
9+
insane: "insane",
10+
} as const;
11+
export const leaderboardRouter = createTRPCRouter({
12+
// Full leaderboard
13+
getAll: publicProcedure.query(async ({ ctx }) => {
14+
// Fetch users -> solved problems
15+
const users = await ctx.db.user.findMany({
16+
select: {
17+
id: true,
18+
name: true,
19+
solvedProblems: {
20+
select: {
21+
level: true,
22+
weekId: true,
23+
},
24+
},
25+
},
26+
});
27+
// Aggregate leaderboard data from database
28+
return users.map((user) => {
29+
const counts = {
30+
warmup: 0,
31+
medium: 0,
32+
harder: 0,
33+
insane: 0,
34+
};
35+
user.solvedProblems.forEach((p) => {
36+
// key for level - if not found, default to warmup
37+
const key =
38+
levelMap[p.level.toLowerCase() as keyof typeof levelMap] || "warmup";
39+
// Increment the count for the corresponding level
40+
if (counts[key] !== undefined) counts[key]++;
41+
});
42+
return {
43+
username: user.name,
44+
completedWarmup: counts.warmup,
45+
completedMedium: counts.medium,
46+
completedHarder: counts.harder,
47+
completedInsane: counts.insane,
48+
total:
49+
counts.warmup +
50+
counts.medium +
51+
counts.harder +
52+
counts.insane,
53+
};
54+
});
55+
}),
56+
// Get leaderboard for a specific week
57+
getByWeek: publicProcedure.input(z.string()).query(async ({ ctx, input }) => {
58+
const users = await ctx.db.user.findMany({
59+
select: {
60+
id: true,
61+
name: true,
62+
solvedProblems: {
63+
where: { weekId: input },
64+
select: { level: true },
65+
},
66+
},
67+
});
68+
69+
return users.map((user) => {
70+
const counts = {
71+
warmup: 0,
72+
medium: 0,
73+
harder: 0,
74+
insane: 0,
75+
};
76+
user.solvedProblems.forEach((p) => {
77+
const key =
78+
levelMap[p.level.toLowerCase() as keyof typeof levelMap] || "warmup";
79+
if (counts[key] !== undefined) counts[key]++;
80+
});
81+
return {
82+
username: user.name,
83+
completedWarmup: counts.warmup,
84+
completedMedium: counts.medium,
85+
completedHarder: counts.harder,
86+
completedInsane: counts.insane,
87+
total:
88+
counts.warmup +
89+
counts.medium +
90+
counts.harder +
91+
counts.insane,
92+
};
93+
});
94+
}),
95+
});

src/server/api/routers/problem.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export const problemRouter = createTRPCRouter({
1313
.input(z.object({
1414
name: z.string(),
1515
level: z.string(),
16-
status: z.string(),
1716
leetcodeUrl: z.string().url(),
1817
weekId: z.string(),
1918
}))
@@ -25,7 +24,6 @@ export const problemRouter = createTRPCRouter({
2524
id: z.string(),
2625
name: z.string().optional(),
2726
level: z.string().optional(),
28-
status: z.string().optional(),
2927
leetcodeUrl: z.string().url().optional(),
3028
weekId: z.string().optional(),
3129
}))

0 commit comments

Comments
 (0)