Skip to content

Commit ba0b309

Browse files
authored
Merge branch 'main' into claude/add-media-scoring-XkPF3
2 parents 6962a42 + e21ef06 commit ba0b309

11 files changed

Lines changed: 1072 additions & 36 deletions

File tree

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
- name: Setup Node.js
2828
uses: actions/setup-node@v4
2929
with:
30-
node-version: '18'
30+
node-version: '22'
3131

3232
- name: Setup pnpm
3333
uses: pnpm/action-setup@v4
@@ -63,7 +63,7 @@ jobs:
6363
- name: Setup Node.js
6464
uses: actions/setup-node@v4
6565
with:
66-
node-version: '18'
66+
node-version: '22'
6767

6868
- name: Setup pnpm
6969
uses: pnpm/action-setup@v4
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { cn } from "@/lib/utils";
5+
import { LeaderboardList } from "./leaderboard-list";
6+
import { WeeklyCategoryLeaderboard } from "./weekly-category-leaderboard";
7+
8+
interface LeaderboardEntry {
9+
rank: number;
10+
user: {
11+
id: string;
12+
name: string | null;
13+
username: string;
14+
avatarUrl: string | null;
15+
};
16+
totalPoints: number;
17+
currentStreak: number;
18+
}
19+
20+
interface LeaderboardTabsProps {
21+
entries: LeaderboardEntry[];
22+
challengeId: string;
23+
currentUserId: string;
24+
}
25+
26+
type Tab = "overall" | "weekly";
27+
28+
export function LeaderboardTabs({
29+
entries,
30+
challengeId,
31+
currentUserId,
32+
}: LeaderboardTabsProps) {
33+
const [activeTab, setActiveTab] = useState<Tab>("overall");
34+
35+
return (
36+
<div>
37+
{/* Tab switcher */}
38+
<div className="mb-6 flex rounded-lg bg-zinc-900/50 p-1">
39+
<button
40+
onClick={() => setActiveTab("overall")}
41+
className={cn(
42+
"flex-1 rounded-md px-4 py-2 text-sm font-medium transition",
43+
activeTab === "overall"
44+
? "bg-zinc-800 text-white shadow-sm"
45+
: "text-zinc-400 hover:text-zinc-300"
46+
)}
47+
>
48+
Overall
49+
</button>
50+
<button
51+
onClick={() => setActiveTab("weekly")}
52+
className={cn(
53+
"flex-1 rounded-md px-4 py-2 text-sm font-medium transition",
54+
activeTab === "weekly"
55+
? "bg-zinc-800 text-white shadow-sm"
56+
: "text-zinc-400 hover:text-zinc-300"
57+
)}
58+
>
59+
Weekly by Category
60+
</button>
61+
</div>
62+
63+
{/* Tab content */}
64+
{activeTab === "overall" ? (
65+
<LeaderboardList
66+
entries={entries}
67+
challengeId={challengeId}
68+
currentUserId={currentUserId}
69+
/>
70+
) : (
71+
<WeeklyCategoryLeaderboard
72+
challengeId={challengeId}
73+
currentUserId={currentUserId}
74+
/>
75+
)}
76+
</div>
77+
);
78+
}

apps/web/app/challenges/[id]/leaderboard/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Id } from "@repo/backend/_generated/dataModel";
66
import { getCurrentUser } from "@/lib/auth";
77
import { isAuthenticated } from "@/lib/server-auth";
88
import { DashboardLayoutWrapper } from "../notifications/dashboard-layout-wrapper";
9-
import { LeaderboardList } from "./leaderboard-list";
9+
import { LeaderboardTabs } from "./leaderboard-tabs";
1010

1111
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
1212

@@ -66,7 +66,7 @@ export default async function LeaderboardPage({ params }: LeaderboardPageProps)
6666
>
6767
<div className="mx-auto max-w-2xl px-4 py-6">
6868
<h1 className="mb-6 text-2xl font-bold">Leaderboard</h1>
69-
<LeaderboardList
69+
<LeaderboardTabs
7070
entries={leaderboardEntries}
7171
challengeId={challenge._id}
7272
currentUserId={user._id}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"use client";
2+
3+
import { useState, useRef } from "react";
4+
import { useQuery } from "convex/react";
5+
import { api } from "@repo/backend";
6+
import type { Id } from "@repo/backend/_generated/dataModel";
7+
import Link from "next/link";
8+
import { ChevronLeft, ChevronRight, Trophy, Loader2 } from "lucide-react";
9+
10+
import { UserAvatar } from "@/components/user-avatar";
11+
import { cn } from "@/lib/utils";
12+
13+
interface WeeklyLeaderboardEntry {
14+
rank: number;
15+
user: {
16+
id: string;
17+
name: string | null;
18+
username: string;
19+
avatarUrl: string | null;
20+
};
21+
weeklyPoints: number;
22+
}
23+
24+
interface CategoryLeaderboard {
25+
category: {
26+
id: string;
27+
name: string;
28+
};
29+
entries: WeeklyLeaderboardEntry[];
30+
}
31+
32+
interface WeeklyCategoryLeaderboardProps {
33+
challengeId: string;
34+
currentUserId: string;
35+
initialWeek?: number;
36+
}
37+
38+
export function WeeklyCategoryLeaderboard({
39+
challengeId,
40+
currentUserId,
41+
initialWeek,
42+
}: WeeklyCategoryLeaderboardProps) {
43+
const [weekNumber, setWeekNumber] = useState(initialWeek ?? 1);
44+
45+
const data = useQuery(api.queries.participations.getWeeklyCategoryLeaderboard, {
46+
challengeId: challengeId as Id<"challenges">,
47+
weekNumber,
48+
});
49+
50+
// Once we have data, if initialWeek wasn't provided, snap to current week
51+
const hasSnapped = useRef(false);
52+
if (data && !hasSnapped.current && !initialWeek) {
53+
hasSnapped.current = true;
54+
if (data.currentWeek >= 1 && data.currentWeek <= data.totalWeeks) {
55+
setWeekNumber(data.currentWeek);
56+
}
57+
}
58+
59+
if (!data) {
60+
return (
61+
<div className="flex items-center justify-center py-12">
62+
<Loader2 className="h-6 w-6 animate-spin text-zinc-500" />
63+
</div>
64+
);
65+
}
66+
67+
const canGoPrev = weekNumber > 1;
68+
const canGoNext = weekNumber < data.totalWeeks;
69+
70+
return (
71+
<div className="space-y-6">
72+
{/* Week navigator */}
73+
<div className="flex items-center justify-center gap-4">
74+
<button
75+
onClick={() => setWeekNumber((w) => Math.max(1, w - 1))}
76+
disabled={!canGoPrev}
77+
className="rounded-lg p-2 text-zinc-400 transition hover:bg-zinc-800 hover:text-white disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-zinc-400"
78+
>
79+
<ChevronLeft className="h-5 w-5" />
80+
</button>
81+
82+
<div className="text-center">
83+
<p className="text-lg font-semibold text-white">
84+
Week {data.weekNumber}
85+
</p>
86+
{data.weekNumber === data.currentWeek && (
87+
<p className="text-xs text-indigo-400">Current week</p>
88+
)}
89+
</div>
90+
91+
<button
92+
onClick={() => setWeekNumber((w) => Math.min(data.totalWeeks, w + 1))}
93+
disabled={!canGoNext}
94+
className="rounded-lg p-2 text-zinc-400 transition hover:bg-zinc-800 hover:text-white disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-zinc-400"
95+
>
96+
<ChevronRight className="h-5 w-5" />
97+
</button>
98+
</div>
99+
100+
{/* Category sections */}
101+
{data.categories.length === 0 ? (
102+
<div className="flex flex-col items-center justify-center py-12 text-center">
103+
<Trophy className="mb-4 h-12 w-12 text-zinc-600" />
104+
<h3 className="text-lg font-medium text-zinc-300">
105+
No activities this week
106+
</h3>
107+
<p className="mt-1 text-sm text-zinc-500">
108+
No one has logged activities for week {data.weekNumber} yet.
109+
</p>
110+
</div>
111+
) : (
112+
(data.categories as CategoryLeaderboard[]).map((category) => (
113+
<div key={category.category.id}>
114+
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-400">
115+
{category.category.name}
116+
</h3>
117+
<div className="space-y-2">
118+
{category.entries.map((entry: WeeklyLeaderboardEntry) => {
119+
const isCurrentUser = entry.user.id === currentUserId;
120+
121+
return (
122+
<Link
123+
key={entry.user.id}
124+
href={`/challenges/${challengeId}/users/${entry.user.id}`}
125+
className={cn(
126+
"flex items-center gap-4 rounded-xl p-3 transition",
127+
isCurrentUser
128+
? "bg-indigo-500/10 ring-1 ring-indigo-500/30 hover:bg-indigo-500/20"
129+
: "bg-zinc-900/50 hover:bg-zinc-800/50"
130+
)}
131+
>
132+
<div className="flex h-7 w-7 items-center justify-center text-base font-bold text-zinc-500">
133+
{entry.rank <= 3 ? (
134+
<Trophy
135+
className={cn(
136+
"h-4 w-4",
137+
entry.rank === 1 && "text-amber-500",
138+
entry.rank === 2 && "text-zinc-400",
139+
entry.rank === 3 && "text-amber-700"
140+
)}
141+
/>
142+
) : (
143+
entry.rank
144+
)}
145+
</div>
146+
147+
<UserAvatar
148+
user={{
149+
id: entry.user.id,
150+
name: entry.user.name,
151+
username: entry.user.username,
152+
avatarUrl: entry.user.avatarUrl,
153+
}}
154+
challengeId={challengeId}
155+
size="sm"
156+
/>
157+
158+
<div className="min-w-0 flex-1">
159+
<p className="truncate text-sm font-medium text-white">
160+
{entry.user.name || entry.user.username}
161+
{isCurrentUser && (
162+
<span className="ml-2 text-xs text-indigo-400">
163+
(You)
164+
</span>
165+
)}
166+
</p>
167+
</div>
168+
169+
<div className="text-right">
170+
<p className="text-sm font-bold text-white">
171+
{entry.weeklyPoints.toFixed(0)}
172+
</p>
173+
<p className="text-xs text-zinc-500">pts</p>
174+
</div>
175+
</Link>
176+
);
177+
})}
178+
</div>
179+
</div>
180+
))
181+
)}
182+
</div>
183+
);
184+
}

0 commit comments

Comments
 (0)