Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the leaderboard experience with a new header layout (banner/back/invite), a new stats sidebar, and a redesigned ranking/podium presentation, along with some related client-component and dependency updates.
Changes:
- Add new leaderboard UI components (Banner, Invite Friends button, Stats sidebar) and integrate them into the leaderboard header/table.
- Redesign leaderboard rankings to include a podium for the top 3 plus a badge/legend system.
- Update dependencies/lockfile (adds
shx) and adjust a few client components / typings.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Adds shx to devDependencies. |
| package-lock.json | Lockfile updated to include shx and many additional metadata changes. |
| app/components/leaderboard/LeaderboardStats.tsx | New stats sidebar (languages/editors/aggregates) with AOS animation hooks. |
| app/components/leaderboard/InviteFriendsButton.tsx | New “Invite Friends” button that copies an invite message to clipboard. |
| app/components/leaderboard/Header.tsx | Replaces simple header with banner-based layout, back button overlay, and invite action. |
| app/components/leaderboard/Banner.tsx | New banner component (image or gradient fallback). |
| app/components/leaderboard/BackButton.tsx | Adds href prop and changes default destination. |
| app/components/LeaderboardTable.tsx | Adds podium UI, badge system + legend, and moves stats into a sidebar layout. |
| app/components/chat/Messages.tsx | Updates SyntaxHighlighter typing/casts in markdown rendering. |
| app/components/chat/Conversations.tsx | Marks component as client-side. |
| app/components/dashboard/widgets/StatsCard.tsx | Marks component as client-side. |
| app/components/dashboard/Navbar.tsx | Removes unused faGear import. |
| app/components/Chat.tsx | Removes unused toast import. |
| app/components/landing-page/TopLeaderbord.tsx | Simplifies categories typing. |
| app/(public)/leaderboard/page.tsx | Uses new BackButton API (href). |
| app/(public)/leaderboard/[slug]/page.tsx | Layout tweaks; back navigation now handled via header. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import React, { useMemo } from "react"; | ||
| import Image from "next/image"; | ||
|
|
||
| export default function Banner({ name, imageUrl }: { name: string, imageUrl?: string }) { | ||
| const gradient = useMemo(() => { | ||
| let hash = 0; |
There was a problem hiding this comment.
This component uses useMemo, but it’s missing a "use client" directive. In the Next.js App Router this will fail to compile as a Server Component. Add "use client" at the top (or remove the hook and compute the gradient without React hooks).
| const daily_average = totalSeconds / Math.max(totalDevs, 1); | ||
|
|
||
| const totalHoursFormatted = formatHours(totalSeconds); | ||
| const avgHoursFormatted = formatHours(daily_average || 0); | ||
| const topLang = languageList[0]?.name || "N/A"; | ||
| const topEditor = editorList[0]?.name || "N/A"; | ||
| const topEditorCount = editorList[0]?.count || 0; | ||
| const topLangProgress = languageList[0]?.percent || 0; | ||
|
|
||
| const statCards = [ | ||
| { | ||
| label: "Total Coding", | ||
| value: totalHoursFormatted, | ||
| sub: "Leaderboard Total", | ||
| trend: `${totalDevs} Dev${totalDevs !== 1 ? 's' : ''}`, | ||
| trendUp: true, | ||
| }, | ||
| { | ||
| label: "Avg Daily Time", | ||
| value: avgHoursFormatted, | ||
| sub: "Per Developer", | ||
| trend: "+12%", | ||
| trendUp: true, | ||
| }, |
There was a problem hiding this comment.
daily_average here is computed as total seconds per developer, but it’s displayed as “Avg Daily Time”. Either rename/relabel this stat (e.g., “Avg per developer”) or compute a true daily average from the underlying stats; otherwise the UI is misleading.
| setTimeout(() => { | ||
| AOS.refresh(); | ||
| }, 200); |
There was a problem hiding this comment.
The setTimeout in this effect isn’t cleared on unmount. Store the timeout id and clear it in the cleanup function to avoid calling AOS.refresh() after unmount.
| setTimeout(() => { | |
| AOS.refresh(); | |
| }, 200); | |
| const timeoutId = setTimeout(() => { | |
| AOS.refresh(); | |
| }, 200); | |
| return () => { | |
| clearTimeout(timeoutId); | |
| }; |
| export default function InviteFriendsButton({ joinCode, leaderboardName }: { joinCode?: string; leaderboardName?: string }) { | ||
| const handleInvite = () => { | ||
| if (typeof window !== "undefined") { | ||
| const inviteUrl = joinCode | ||
| ? `${window.location.origin}/join/${joinCode}` | ||
| : window.location.href; // fallback | ||
|
|
||
| const message = leaderboardName | ||
| ? `Join my coding leaderboard "${leaderboardName}" on DevPulse!\n\nTrack metrics, compete with fellow developers, and showcase your engineering skills.\n\nJoin here: ${inviteUrl}` | ||
| : `Join my coding leaderboard on DevPulse!\n\nJoin here: ${inviteUrl}`; | ||
|
|
||
| navigator.clipboard.writeText(message); | ||
| toast.success("Invite message copied to clipboard!"); | ||
| } |
There was a problem hiding this comment.
navigator.clipboard.writeText(...) returns a promise and can fail (non-secure context, permission denied, unsupported). Await it and handle errors (show a toast error), and guard for navigator.clipboard being unavailable. Also consider setting type="button" to avoid accidental form submission if this button is ever placed inside a form.
| @@ -263,15 +280,15 @@ export default function LeaderboardTable({ | |||
| {/* Header Row (Desktop) */} | |||
| <div className="hidden md:flex items-center px-4 sm:px-6 py-4 border-b border-white/5 bg-white/[0.01] text-[10px] font-bold text-gray-500 uppercase tracking-widest"> | |||
| <div className="w-12 shrink-0 text-center">Rank</div> | |||
| <div className="flex-1 ml-4">Developer</div> | |||
| <div className="w-48 lg:w-72 xl:w-80">Tech Stack</div> | |||
| <div className="w-32 lg:w-48">Environment</div> | |||
| <div className="w-24 text-right">Score</div> | |||
| <div className="flex-1 ml-4 min-w-[150px]">Developer</div> | |||
| <div className="w-40 md:w-48 lg:w-48 xl:w-64">Language</div> | |||
| <div className="w-24 md:w-32 lg:w-32 xl:w-48">Editor</div> | |||
| <div className="w-24 text-right">Hours</div> | |||
| </div> | |||
|
|
|||
| {/* List Body */} | |||
| <div className="flex flex-col divide-y divide-white/5"> | |||
| {ranked.map((user) => { | |||
| {ranked.slice(3).map((user) => { | |||
| const isCurrentUser = user.user_id === ownerId; | |||
There was a problem hiding this comment.
When ranked.length is 1–3, the podium renders but the table renders with headers and an empty body because of ranked.slice(3). Consider conditionally hiding the table section when there are no rows beyond the podium, or include the top 3 in the list as well.
| const secs = typeof l === "string" ? 3600 : l.total_seconds || 3600; | ||
| languageTime[name] = (languageTime[name] || 0) + secs; | ||
| }); | ||
| (m.editors as { name?: string; total_seconds?: number }[] || []).forEach((e: { name?: string; total_seconds?: number } | string) => { | ||
| const name = typeof e === "string" ? e : e.name || "Unknown"; | ||
| const secs = typeof e === "string" ? 3600 : e.total_seconds || 3600; |
There was a problem hiding this comment.
NonNullableMember.languages/editors are typed as { name: string }[] (no total_seconds). With the current fallback of || 3600, any missing total_seconds will add 1 hour per entry, inflating totals and percentages (often > 100%). Default missing total_seconds to 0 (and consider aligning the member types to the actual Supabase JSON shape).
| const secs = typeof l === "string" ? 3600 : l.total_seconds || 3600; | |
| languageTime[name] = (languageTime[name] || 0) + secs; | |
| }); | |
| (m.editors as { name?: string; total_seconds?: number }[] || []).forEach((e: { name?: string; total_seconds?: number } | string) => { | |
| const name = typeof e === "string" ? e : e.name || "Unknown"; | |
| const secs = typeof e === "string" ? 3600 : e.total_seconds || 3600; | |
| const secs = typeof l === "string" ? 3600 : (l.total_seconds ?? 0); | |
| languageTime[name] = (languageTime[name] || 0) + secs; | |
| }); | |
| (m.editors as { name?: string; total_seconds?: number }[] || []).forEach((e: { name?: string; total_seconds?: number } | string) => { | |
| const name = typeof e === "string" ? e : e.name || "Unknown"; | |
| const secs = typeof e === "string" ? 3600 : (e.total_seconds ?? 0); |
|
|
||
| import { useEffect } from "react"; | ||
| import AOS from "aos"; | ||
| import "aos/dist/aos.css"; |
There was a problem hiding this comment.
Global AOS CSS is already imported in app/components/AOSWrapper.tsx. Importing it again here is redundant and increases CSS duplication/bundle size; prefer a single global import location.
| import "aos/dist/aos.css"; |
| import LeaderboardStats from "./leaderboard/LeaderboardStats"; | ||
|
|
||
| export default function LeaderboardTable({ |
There was a problem hiding this comment.
This import appears mid-file. While syntactically valid, it breaks the project’s typical import organization and can confuse tooling. Move import LeaderboardStats ... to the top with the other imports.
| {/* Using a temporary placeholder banner image */} | ||
| <Banner name={leaderboard.name} imageUrl="https://images.unsplash.com/photo-1550751827-4bd374c3f58b?q=80&w=2070&auto=format&fit=crop" /> |
There was a problem hiding this comment.
This hard-coded Unsplash URL is labeled as a “temporary placeholder”. If this isn’t meant to ship, prefer omitting imageUrl so the gradient fallback is used, or source the banner image from persisted leaderboard data/config instead of a baked-in external URL.
| {/* Using a temporary placeholder banner image */} | |
| <Banner name={leaderboard.name} imageUrl="https://images.unsplash.com/photo-1550751827-4bd374c3f58b?q=80&w=2070&auto=format&fit=crop" /> | |
| {/* Using the default banner background (gradient fallback) */} | |
| <Banner name={leaderboard.name} /> |
| <div className="group relative mb-20 sm:mb-24"> | ||
| {/* Using a temporary placeholder banner image */} | ||
| <Banner name={leaderboard.name} imageUrl="https://images.unsplash.com/photo-1550751827-4bd374c3f58b?q=80&w=2070&auto=format&fit=crop" /> | ||
|
|
There was a problem hiding this comment.
PR title/description say “update navigation”, but this change set also adds new leaderboard UI components, animations, and dependencies. Please update the PR description to reflect the full scope (or split into smaller PRs) so reviewers can assess impact accurately.
update navigation