Skip to content

Commit cf2b361

Browse files
prazgaitisclaude
andcommitted
Add challenge finished state, winners display, and truncate leaderboard decimals
Schema: - Add finishedAt (timestamp) and winners (array of userId/placement/label) to challenges table Backend: - Block activity logging when challenge.finishedAt is set - updateChallenge accepts finishedAt and winners; finishedAt=0 clears it Frontend: - WinnersBanner component with gradient cards, placement icons, tie support - Winners shown on leaderboard page (top) and challenge index page - Admin /winners page: toggle finished state, configure winners with user dropdown from leaderboard, placement numbers for ties, optional labels, live preview of WinnersBanner - Leaderboard points truncated to whole numbers (Math.trunc + toLocaleString) - Profile/detail views keep full decimal precision - "Winners" added to admin nav under Manage group Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1acce0d commit cf2b361

12 files changed

Lines changed: 575 additions & 0 deletions

File tree

apps/web/app/challenges/[id]/(dashboard)/leaderboard/leaderboard-list.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ const LeaderboardEntryRow = memo(function LeaderboardEntryRow({
106106
<div className="ml-auto flex shrink-0 items-center gap-1">
107107
<PointsDisplay
108108
points={entry.totalPoints}
109+
decimals={0}
109110
size="lg"
110111
showSign={false}
111112
showLabel={false}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Id } from "@repo/backend/_generated/dataModel";
55

66
import { getCurrentUser } from "@/lib/auth";
77
import { LeaderboardTabs } from "./leaderboard-tabs";
8+
import { WinnersBanner } from "@/components/challenges/winners-banner";
89

910
interface LeaderboardPageProps {
1011
params: Promise<{ id: string }>;
@@ -26,9 +27,24 @@ export default async function LeaderboardPage({ params }: LeaderboardPageProps)
2627

2728
if (!challenge) notFound();
2829

30+
// Enrich winners with user data and points from the leaderboard
31+
const winners = (challenge.winners ?? []).map((w: any) => {
32+
const entry = leaderboardEntries.find(
33+
(e: any) => e.user.id === w.userId,
34+
);
35+
return {
36+
...w,
37+
user: entry?.user ?? null,
38+
totalPoints: entry?.totalPoints,
39+
};
40+
});
41+
2942
return (
3043
<div className="mx-auto max-w-2xl px-4 py-6">
3144
<h1 className="mb-6 text-2xl font-bold">Leaderboard</h1>
45+
{winners.length > 0 && (
46+
<WinnersBanner winners={winners} className="mb-6" />
47+
)}
3248
<LeaderboardTabs
3349
entries={leaderboardEntries}
3450
challengeId={challenge._id}

apps/web/app/challenges/[id]/admin/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export default async function ChallengeAdminLayout({
8383
{ href: `${base}/payments`, label: "Payments", segment: "payments" },
8484
{ href: `${base}/emails`, label: "Emails", segment: "emails" },
8585
{ href: `${base}/feedback`, label: "Feedback", segment: "feedback" },
86+
{ href: `${base}/winners`, label: "Winners", segment: "winners" },
8687
{ href: `${base}/exports`, label: "Exports", segment: "exports" },
8788
{ href: `${base}/settings`, label: "Settings", segment: "settings" },
8889
],
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { useParams } from "next/navigation";
5+
import { useQuery, useMutation } from "@/lib/convex-auth-react";
6+
import { api } from "@repo/backend";
7+
import type { Id } from "@repo/backend/_generated/dataModel";
8+
import {
9+
Crown,
10+
Loader2,
11+
Lock,
12+
Plus,
13+
Trash2,
14+
Trophy,
15+
Unlock,
16+
} from "lucide-react";
17+
import { Button } from "@/components/ui/button";
18+
import { cn } from "@/lib/utils";
19+
import { WinnersBanner } from "@/components/challenges/winners-banner";
20+
21+
interface WinnerEntry {
22+
userId: string;
23+
placement: number;
24+
label?: string;
25+
}
26+
27+
export default function WinnersAdminPage() {
28+
const params = useParams();
29+
const challengeId = params.id as string;
30+
const [isSaving, setIsSaving] = useState(false);
31+
32+
const challenge = useQuery(api.queries.challenges.getById, {
33+
challengeId: challengeId as Id<"challenges">,
34+
});
35+
36+
const leaderboard = useQuery(
37+
api.queries.participations.getFullLeaderboard,
38+
{ challengeId: challengeId as Id<"challenges"> },
39+
);
40+
41+
const updateChallenge = useMutation(
42+
api.mutations.challenges.updateChallenge,
43+
);
44+
45+
const isFinished = !!challenge?.finishedAt;
46+
const currentWinners: WinnerEntry[] = (challenge?.winners as WinnerEntry[]) ?? [];
47+
48+
// Local state for editing winners
49+
const [editingWinners, setEditingWinners] = useState<WinnerEntry[] | null>(null);
50+
const winners = editingWinners ?? currentWinners;
51+
52+
const setWinners = (w: WinnerEntry[]) => setEditingWinners(w);
53+
54+
const isDirty = editingWinners !== null;
55+
56+
async function handleToggleFinished() {
57+
const action = isFinished ? "reopen" : "finalize";
58+
if (
59+
!confirm(
60+
isFinished
61+
? "Reopen this challenge? Users will be able to log activities again."
62+
: "Mark this challenge as finished? Users will no longer be able to log activities.",
63+
)
64+
)
65+
return;
66+
67+
setIsSaving(true);
68+
try {
69+
await updateChallenge({
70+
challengeId: challengeId as Id<"challenges">,
71+
finishedAt: isFinished ? (0 as any) : Date.now(),
72+
});
73+
} finally {
74+
setIsSaving(false);
75+
}
76+
}
77+
78+
async function handleSaveWinners() {
79+
setIsSaving(true);
80+
try {
81+
await updateChallenge({
82+
challengeId: challengeId as Id<"challenges">,
83+
winners: winners.map((w) => ({
84+
userId: w.userId as Id<"users">,
85+
placement: w.placement,
86+
label: w.label || undefined,
87+
})),
88+
});
89+
setEditingWinners(null);
90+
} finally {
91+
setIsSaving(false);
92+
}
93+
}
94+
95+
function addWinner() {
96+
const nextPlacement = winners.length > 0
97+
? Math.max(...winners.map((w) => w.placement)) + 1
98+
: 1;
99+
setWinners([...winners, { userId: "", placement: nextPlacement }]);
100+
}
101+
102+
function removeWinner(index: number) {
103+
setWinners(winners.filter((_, i) => i !== index));
104+
}
105+
106+
function updateWinner(index: number, updates: Partial<WinnerEntry>) {
107+
setWinners(
108+
winners.map((w, i) => (i === index ? { ...w, ...updates } : w)),
109+
);
110+
}
111+
112+
if (!challenge) {
113+
return (
114+
<div className="flex items-center justify-center py-20">
115+
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
116+
</div>
117+
);
118+
}
119+
120+
// Build preview winners with user data from leaderboard
121+
const previewWinners = winners
122+
.filter((w) => w.userId)
123+
.map((w) => {
124+
const entry = leaderboard?.find((e: any) => e.user.id === w.userId);
125+
return {
126+
...w,
127+
user: entry?.user ?? null,
128+
totalPoints: entry?.totalPoints,
129+
};
130+
});
131+
132+
return (
133+
<div className="mx-auto max-w-3xl space-y-6">
134+
{/* Header */}
135+
<div>
136+
<h1 className="text-lg font-bold text-white">Winners & Finish</h1>
137+
<p className="mt-0.5 text-xs text-zinc-500">
138+
Mark the challenge as finished and configure winners
139+
</p>
140+
</div>
141+
142+
{/* Finish Toggle */}
143+
<div
144+
className={cn(
145+
"flex items-center justify-between rounded-lg border px-4 py-3",
146+
isFinished
147+
? "border-amber-500/30 bg-amber-500/5"
148+
: "border-zinc-800 bg-zinc-900",
149+
)}
150+
>
151+
<div className="flex items-center gap-3">
152+
{isFinished ? (
153+
<Lock className="h-5 w-5 text-amber-400" />
154+
) : (
155+
<Unlock className="h-5 w-5 text-zinc-500" />
156+
)}
157+
<div>
158+
<div className="text-sm font-semibold text-white">
159+
{isFinished ? "Challenge Finished" : "Challenge Open"}
160+
</div>
161+
<div className="text-xs text-zinc-500">
162+
{isFinished
163+
? "Activity logging is locked. Users see the winners banner."
164+
: "Users can still log activities."}
165+
</div>
166+
</div>
167+
</div>
168+
<Button
169+
variant={isFinished ? "outline" : "default"}
170+
size="sm"
171+
onClick={handleToggleFinished}
172+
disabled={isSaving}
173+
className={cn(
174+
isFinished
175+
? "border-zinc-700 text-zinc-300 hover:bg-zinc-800"
176+
: "bg-amber-600 hover:bg-amber-500",
177+
)}
178+
>
179+
{isSaving ? (
180+
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
181+
) : isFinished ? (
182+
<Unlock className="mr-2 h-3 w-3" />
183+
) : (
184+
<Lock className="mr-2 h-3 w-3" />
185+
)}
186+
{isFinished ? "Reopen" : "Mark Finished"}
187+
</Button>
188+
</div>
189+
190+
{/* Winners Configuration */}
191+
<div className="rounded-lg border border-zinc-800 bg-zinc-900">
192+
<div className="border-b border-zinc-800/50 px-4 py-3">
193+
<div className="flex items-center gap-2">
194+
<Trophy className="h-4 w-4 text-amber-400" />
195+
<span className="text-sm font-semibold text-white">
196+
Configure Winners
197+
</span>
198+
</div>
199+
<p className="mt-1 text-xs text-zinc-500">
200+
Set placement and select users. Use the same placement number for
201+
ties.
202+
</p>
203+
</div>
204+
205+
<div className="space-y-2 p-4">
206+
{winners.map((winner, index) => (
207+
<div
208+
key={index}
209+
className="flex items-center gap-2 rounded-md border border-zinc-800/50 bg-zinc-950 px-3 py-2"
210+
>
211+
{/* Placement */}
212+
<div className="flex items-center gap-1">
213+
<Crown className="h-3.5 w-3.5 text-amber-400" />
214+
<input
215+
type="number"
216+
min={1}
217+
value={winner.placement}
218+
onChange={(e) =>
219+
updateWinner(index, {
220+
placement: parseInt(e.target.value) || 1,
221+
})
222+
}
223+
className="w-12 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-center font-mono text-sm text-white"
224+
/>
225+
</div>
226+
227+
{/* User Select */}
228+
<select
229+
value={winner.userId}
230+
onChange={(e) =>
231+
updateWinner(index, { userId: e.target.value })
232+
}
233+
className="flex-1 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-white"
234+
>
235+
<option value="">Select user...</option>
236+
{leaderboard?.map((entry: any) => (
237+
<option key={entry.user.id} value={entry.user.id}>
238+
{entry.user.name ?? entry.user.username} (
239+
{Math.trunc(entry.totalPoints).toLocaleString()} pts)
240+
</option>
241+
))}
242+
</select>
243+
244+
{/* Label */}
245+
<input
246+
type="text"
247+
value={winner.label ?? ""}
248+
onChange={(e) =>
249+
updateWinner(index, {
250+
label: e.target.value || undefined,
251+
})
252+
}
253+
placeholder="Label (optional)"
254+
className="w-40 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-300 placeholder:text-zinc-600"
255+
/>
256+
257+
{/* Remove */}
258+
<button
259+
onClick={() => removeWinner(index)}
260+
className="text-zinc-600 hover:text-red-400"
261+
>
262+
<Trash2 className="h-3.5 w-3.5" />
263+
</button>
264+
</div>
265+
))}
266+
267+
<div className="flex items-center gap-2 pt-2">
268+
<Button
269+
variant="ghost"
270+
size="sm"
271+
onClick={addWinner}
272+
className="text-zinc-400 hover:text-white"
273+
>
274+
<Plus className="mr-1 h-3 w-3" />
275+
Add Winner
276+
</Button>
277+
278+
{isDirty && (
279+
<div className="ml-auto flex items-center gap-2">
280+
<Button
281+
variant="ghost"
282+
size="sm"
283+
onClick={() => setEditingWinners(null)}
284+
className="text-zinc-500"
285+
>
286+
Cancel
287+
</Button>
288+
<Button
289+
size="sm"
290+
onClick={handleSaveWinners}
291+
disabled={isSaving}
292+
className="bg-amber-600 hover:bg-amber-500"
293+
>
294+
{isSaving && (
295+
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
296+
)}
297+
Save Winners
298+
</Button>
299+
</div>
300+
)}
301+
</div>
302+
</div>
303+
</div>
304+
305+
{/* Preview */}
306+
{previewWinners.length > 0 && (
307+
<div>
308+
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-500">
309+
Preview
310+
</h3>
311+
<WinnersBanner winners={previewWinners} />
312+
</div>
313+
)}
314+
</div>
315+
);
316+
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,23 @@ export default async function ChallengePage({ params }: PageProps) {
8686
notFound();
8787
}
8888

89+
// Enrich winners with user data if present
90+
let enrichedWinners: Array<any> | undefined;
91+
if (challenge.winners?.length) {
92+
const leaderboard = await convex.query(
93+
api.queries.participations.getFullLeaderboard,
94+
{ challengeId },
95+
);
96+
enrichedWinners = challenge.winners.map((w: any) => {
97+
const entry = leaderboard.find((e: any) => e.user.id === w.userId);
98+
return {
99+
...w,
100+
user: entry?.user ?? null,
101+
totalPoints: entry?.totalPoints,
102+
};
103+
});
104+
}
105+
89106
return (
90107
<>
91108
<Suspense fallback={<HeaderSkeleton />}>
@@ -96,6 +113,7 @@ export default async function ChallengePage({ params }: PageProps) {
96113
...challenge,
97114
startDate: challenge.startDate,
98115
endDate: challenge.endDate,
116+
winners: enrichedWinners,
99117
}}
100118
isParticipating={isParticipating}
101119
isSignedIn={Boolean(user)}

0 commit comments

Comments
 (0)