Skip to content

Commit 62cc348

Browse files
committed
Add weekly category leaderboard view to leaderboard page
Adds a tabbed leaderboard with "Overall" and "Weekly by Category" views. The weekly view shows top performers per activity category for each challenge week, with prev/next navigation defaulting to the current week. Backend: new getWeeklyCategoryLeaderboard query using challengeLoggedDate index for efficient date-range filtering. Shared week utilities extracted to packages/backend/lib/weeks.ts. https://claude.ai/code/session_01HDFLHQHH8KLunY6xYbkmaJ
1 parent 1d6135c commit 62cc348

7 files changed

Lines changed: 507 additions & 34 deletions

File tree

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+
}

packages/backend/lib/weeks.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { dateOnlyToUtcMs } from "./dateOnly";
2+
3+
/**
4+
* Get the week number of the challenge for a given date.
5+
* Week 1 = days 0-6, Week 2 = days 7-13, etc.
6+
* Returns 0 if the date is before the challenge starts.
7+
*/
8+
export function getChallengeWeekNumber(
9+
challengeStartDate: string | number,
10+
loggedDate: number
11+
): number {
12+
const startDate = new Date(dateOnlyToUtcMs(challengeStartDate));
13+
const loggedDateObj = new Date(loggedDate);
14+
15+
const startDayUtc = Date.UTC(
16+
startDate.getUTCFullYear(),
17+
startDate.getUTCMonth(),
18+
startDate.getUTCDate()
19+
);
20+
21+
const loggedDayUtc = Date.UTC(
22+
loggedDateObj.getUTCFullYear(),
23+
loggedDateObj.getUTCMonth(),
24+
loggedDateObj.getUTCDate()
25+
);
26+
27+
const daysSinceStart = Math.floor(
28+
(loggedDayUtc - startDayUtc) / (1000 * 60 * 60 * 24)
29+
);
30+
31+
if (daysSinceStart < 0) {
32+
return 0;
33+
}
34+
35+
return Math.floor(daysSinceStart / 7) + 1;
36+
}
37+
38+
/**
39+
* Get the UTC timestamp range [start, end) for a given challenge week number.
40+
* Week 1 covers days 0-6, Week 2 covers days 7-13, etc.
41+
*/
42+
export function getWeekDateRange(
43+
challengeStartDate: string | number,
44+
weekNumber: number
45+
): { start: number; end: number } {
46+
const startMs = dateOnlyToUtcMs(challengeStartDate);
47+
const startDate = new Date(startMs);
48+
const startDayUtc = Date.UTC(
49+
startDate.getUTCFullYear(),
50+
startDate.getUTCMonth(),
51+
startDate.getUTCDate()
52+
);
53+
54+
const msPerDay = 1000 * 60 * 60 * 24;
55+
const weekStart = startDayUtc + (weekNumber - 1) * 7 * msPerDay;
56+
const weekEnd = weekStart + 7 * msPerDay;
57+
58+
return { start: weekStart, end: weekEnd };
59+
}
60+
61+
/**
62+
* Get the total number of weeks in a challenge.
63+
*/
64+
export function getTotalWeeks(durationDays: number): number {
65+
return Math.ceil(durationDays / 7);
66+
}

packages/backend/mutations/activities.ts

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getCurrentUser } from "../lib/ids";
55
import { isPaymentRequired } from "../lib/payments";
66
import type { Id } from "../_generated/dataModel";
77
import { dateOnlyToUtcMs } from "../lib/dateOnly";
8+
import { getChallengeWeekNumber } from "../lib/weeks";
89

910
const DAY_MS = 24 * 60 * 60 * 1000;
1011

@@ -573,38 +574,6 @@ function getWeekStart(timestamp: number): number {
573574
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), diff);
574575
}
575576

576-
/**
577-
* Get the week number of the challenge for a given date
578-
* Week 1 = days 1-7, Week 2 = days 8-14, etc.
579-
* Returns 0 if the date is before the challenge starts
580-
*/
581-
function getChallengeWeekNumber(challengeStartDate: string | number, loggedDate: number): number {
582-
// Normalize both to start of day UTC
583-
const startDate = new Date(dateOnlyToUtcMs(challengeStartDate));
584-
const loggedDateObj = new Date(loggedDate);
585-
586-
const startDayUtc = Date.UTC(
587-
startDate.getUTCFullYear(),
588-
startDate.getUTCMonth(),
589-
startDate.getUTCDate()
590-
);
591-
592-
const loggedDayUtc = Date.UTC(
593-
loggedDateObj.getUTCFullYear(),
594-
loggedDateObj.getUTCMonth(),
595-
loggedDateObj.getUTCDate()
596-
);
597-
598-
// Calculate days since challenge start (0-indexed)
599-
const daysSinceStart = Math.floor((loggedDayUtc - startDayUtc) / (1000 * 60 * 60 * 24));
600-
601-
if (daysSinceStart < 0) {
602-
return 0; // Before challenge started
603-
}
604-
605-
// Week 1 = days 0-6, Week 2 = days 7-13, etc.
606-
return Math.floor(daysSinceStart / 7) + 1;
607-
}
608577

609578
// Generate an upload URL for activity media
610579
export const generateUploadUrl = mutation({

0 commit comments

Comments
 (0)