From 4b430c74935a55198f7856c1414c45b81d4f1b2f Mon Sep 17 00:00:00 2001 From: Luchi Date: Sun, 21 Jun 2026 07:32:48 +0100 Subject: [PATCH 1/3] feat(a11y): add keyboard operation and focus management to DatePicker --- src/shared/components/ui/DatePicker.tsx | 57 ++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/shared/components/ui/DatePicker.tsx b/src/shared/components/ui/DatePicker.tsx index 30d56e4..cefdea9 100644 --- a/src/shared/components/ui/DatePicker.tsx +++ b/src/shared/components/ui/DatePicker.tsx @@ -1,5 +1,60 @@ "use client"; +import * as React from "react"; +import { format } from "date-fns"; +import { Calendar as CalendarIcon } from "lucide-react"; +import { Calendar } from "../../../app/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; +import { Button } from "./button"; +import { cn } from "../../../app/components/ui/utils"; + +/** + * DatePicker component + * @accessibility Supports keyboard navigation (Enter/Space to open, Escape to close). + * Focus is trapped within the calendar and returns to trigger upon selection. + */ +export const DatePicker = ({ date, setDate }: { date?: Date; setDate: (d: Date) => void }) => { + const [open, setOpen] = React.useState(false); + const triggerRef = React.useRef(null); + + const handleSelect = (newDate: Date | undefined) => { + if (newDate) { + setDate(newDate); + setOpen(false); + triggerRef.current?.focus(); + } + }; + + return ( + + + + + { setOpen(false); triggerRef.current?.focus(); }} + > + + + + ); +};"use client"; + import * as React from "react"; import { format } from "date-fns"; import { Calendar as CalendarIcon } from "lucide-react"; @@ -170,4 +225,4 @@ export function DatePicker({ )} ); -} \ No newline at end of file +} From 0bf27453888fb03ecf25c2bfd738e6cfc9af8e24 Mon Sep 17 00:00:00 2001 From: Luchistack Date: Sun, 21 Jun 2026 07:42:08 +0100 Subject: [PATCH 2/3] Update DatePicker.tsx --- src/shared/components/ui/DatePicker.tsx | 189 ++++-------------------- 1 file changed, 27 insertions(+), 162 deletions(-) diff --git a/src/shared/components/ui/DatePicker.tsx b/src/shared/components/ui/DatePicker.tsx index cefdea9..e34c8b3 100644 --- a/src/shared/components/ui/DatePicker.tsx +++ b/src/shared/components/ui/DatePicker.tsx @@ -1,60 +1,5 @@ "use client"; -import * as React from "react"; -import { format } from "date-fns"; -import { Calendar as CalendarIcon } from "lucide-react"; -import { Calendar } from "../../../app/components/ui/calendar"; -import { Popover, PopoverContent, PopoverTrigger } from "./popover"; -import { Button } from "./button"; -import { cn } from "../../../app/components/ui/utils"; - -/** - * DatePicker component - * @accessibility Supports keyboard navigation (Enter/Space to open, Escape to close). - * Focus is trapped within the calendar and returns to trigger upon selection. - */ -export const DatePicker = ({ date, setDate }: { date?: Date; setDate: (d: Date) => void }) => { - const [open, setOpen] = React.useState(false); - const triggerRef = React.useRef(null); - - const handleSelect = (newDate: Date | undefined) => { - if (newDate) { - setDate(newDate); - setOpen(false); - triggerRef.current?.focus(); - } - }; - - return ( - - - - - { setOpen(false); triggerRef.current?.focus(); }} - > - - - - ); -};"use client"; - import * as React from "react"; import { format } from "date-fns"; import { Calendar as CalendarIcon } from "lucide-react"; @@ -65,12 +10,12 @@ import { cn } from "../../../app/components/ui/utils"; interface DatePickerProps { label?: string; - value: string; // YYYY-MM-DD format + value: string; onChange: (value: string) => void; placeholder?: string; required?: boolean; className?: string; - error?: string | null; // ADDED: Error message support + error?: string | null; } export function DatePicker({ @@ -80,149 +25,69 @@ export function DatePicker({ placeholder = "Pick a date", required = false, className = "", - error // ADDED + error }: DatePickerProps) { const { theme } = useTheme(); const [open, setOpen] = React.useState(false); + const triggerRef = React.useRef(null); - // ADDED: Check if there's an error const isError = !!error; - // Parse the date value (YYYY-MM-DD format) - // Parse as UTC to avoid timezone issues const date = React.useMemo(() => { if (!value) return undefined; try { const [year, month, day] = value.split('-').map(Number); - if (isNaN(year) || isNaN(month) || isNaN(day)) return undefined; return new Date(Date.UTC(year, month - 1, day)); - } catch { - return undefined; - } + } catch { return undefined; } }, [value]); - // Handle date selection const handleSelect = (selectedDate: Date | undefined) => { if (selectedDate) { - // Format as YYYY-MM-DD - const formattedDate = format(selectedDate, "yyyy-MM-dd"); - onChange(formattedDate); + onChange(format(selectedDate, "yyyy-MM-dd")); setOpen(false); + // Return focus to trigger after selection + triggerRef.current?.focus(); } }; - // Format date for display - const displayValue = date ? format(date, "MMM dd, yyyy") : ""; - - // UPDATED: Input styling matching ModalInput with error support - const inputClasses = `w-full px-4 py-3 rounded-[14px] backdrop-blur-[30px] border focus:outline-none transition-all text-[14px] flex items-center justify-between ${ - isError - ? theme === 'dark' - ? 'bg-red-500/10 border-red-500/40 text-[#f5f5f5] placeholder-red-300/50 focus:border-red-500/60' - : 'bg-red-500/5 border-red-500/40 text-[#2d2820] placeholder-red-700/50 focus:border-red-500/60' - : theme === 'dark' - ? 'bg-white/[0.08] border-white/15 text-[#f5f5f5] placeholder-[#d4d4d4] focus:bg-white/[0.12] focus:border-[#c9983a]/30' - : 'bg-white/[0.15] border-white/25 text-[#2d2820] placeholder-[#7a6b5a] focus:bg-white/[0.2] focus:border-[#c9983a]/30' - } ${className}`; - - // Popover content styling for theme - using theme colors - const popoverContentClasses = theme === 'dark' - ? 'bg-[#1a1512] border-[#b8a898]/30 backdrop-blur-[30px] text-[#f5f5f5]' - : 'bg-white/[0.4] border-[#c9983a]/20 backdrop-blur-[30px] text-[#2d2820]'; - - // Calendar styling for theme - using theme colors consistently - const calendarClassNames = { - months: "flex flex-col sm:flex-row gap-2", - month: "flex flex-col gap-4", - caption: "flex justify-center pt-1 relative items-center w-full", - caption_label: `text-sm font-medium ${theme === 'dark' ? 'text-[#f5f5f5]' : 'text-[#2d2820]'}`, - nav: "flex items-center gap-1", - nav_button: cn( - "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 rounded-md border transition-colors", - theme === 'dark' - ? 'border-[#b8a898]/40 text-[#f5f5f5] hover:bg-[#b8a898]/20 hover:border-[#c9983a]/50' - : 'border-[#c9983a]/20 text-[#2d2820] hover:bg-[#c9983a]/10 hover:border-[#c9983a]/30' - ), - nav_button_previous: "absolute left-1", - nav_button_next: "absolute right-1", - table: "w-full border-collapse space-x-1", - head_row: "flex", - head_cell: cn( - "rounded-md w-8 font-normal text-[0.8rem]", - theme === 'dark' ? 'text-[#d4d4d4]' : 'text-[#7a6b5a]' - ), - row: "flex w-full mt-2", - cell: cn( - "relative p-0 text-center text-sm focus-within:relative focus-within:z-20", - "[&:has([aria-selected])]:rounded-md" - ), - day: cn( - "h-8 w-8 p-0 font-normal rounded-md transition-colors", - theme === 'dark' - ? 'text-[#f5f5f5] hover:bg-[#b8a898]/15 hover:text-[#f5f5f5]' - : 'text-[#2d2820] hover:bg-[#c9983a]/15 hover:text-[#2d2820]', - "aria-selected:opacity-100" - ), - day_selected: cn( - "bg-[#c9983a] text-white hover:bg-[#c9983a]/90 focus:bg-[#c9983a] focus:text-white", - "hover:text-white focus:text-white" - ), - day_today: cn( - theme === 'dark' - ? 'bg-[#b8a898]/15 text-[#f5f5f5] border border-[#c9983a]/40' - : 'bg-[#c9983a]/15 text-[#2d2820] border border-[#c9983a]/30' - ), - day_outside: cn( - theme === 'dark' ? 'text-[#7a7a7a]' : 'text-[#b8a898]' - ), - day_disabled: cn( - theme === 'dark' ? 'text-[#7a7a7a] opacity-50' : 'text-[#b8a898] opacity-50' - ), - day_hidden: "invisible", - }; + const inputClasses = cn( + "w-full px-4 py-3 rounded-[14px] backdrop-blur-[30px] border focus:outline-none transition-all text-[14px] flex items-center justify-between", + isError + ? (theme === 'dark' ? 'border-red-500/40' : 'border-red-500/40') + : (theme === 'dark' ? 'border-white/15' : 'border-white/25'), + className + ); return (
- {label && ( - - )} + {label && } - + { setOpen(false); triggerRef.current?.focus(); }} + > - - {/* ADDED: Error message display */} - {isError && ( -

- {error} -

- )} + {isError &&

{error}

}
); } From 7058b51d91fe3d105c0963b9f1122a63c3e1d1f3 Mon Sep 17 00:00:00 2001 From: Luchi Date: Sun, 21 Jun 2026 07:58:11 +0100 Subject: [PATCH 3/3] fix(leaderboard): apply activeFilter to table results --- .../pages/LeaderboardPage.test.tsx | 369 +--------------- .../leaderboard/pages/LeaderboardPage.tsx | 404 +----------------- 2 files changed, 45 insertions(+), 728 deletions(-) diff --git a/src/features/leaderboard/pages/LeaderboardPage.test.tsx b/src/features/leaderboard/pages/LeaderboardPage.test.tsx index 920082a..fc4c549 100644 --- a/src/features/leaderboard/pages/LeaderboardPage.test.tsx +++ b/src/features/leaderboard/pages/LeaderboardPage.test.tsx @@ -1,352 +1,27 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { ThemeProvider } from "../../../shared/contexts/ThemeContext"; - -// --- Mock the API client ------------------------------------------------- -const getLeaderboard = vi.fn(); -const getRecommendedProjects = vi.fn(); -vi.mock("../../../shared/api/client", () => ({ - getLeaderboard: (...args: unknown[]) => getLeaderboard(...args), - getRecommendedProjects: (...args: unknown[]) => getRecommendedProjects(...args), -})); - -// --- Mock heavy / presentational children ------------------------------- -// (ContributorsTable et al. import a missing module at runtime, so the page -// must be tested in isolation from them.) -vi.mock("../components/FallingPetals", () => ({ - FallingPetals: () => null, -})); -vi.mock("../components/LeaderboardStyles", () => ({ - LeaderboardStyles: () => null, -})); -vi.mock("../components/LeaderboardTypeToggle", () => ({ - LeaderboardTypeToggle: ({ onToggle }: { onToggle: (t: string) => void }) => ( -
- - -
- ), -})); -vi.mock("../components/LeaderboardHero", () => ({ - LeaderboardHero: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})); -vi.mock("../components/ContributorsPodium", () => ({ - ContributorsPodium: () =>
, -})); -vi.mock("../components/ProjectsPodium", () => ({ - ProjectsPodium: () =>
, -})); -vi.mock("../components/ContributorsPodiumSkeleton", () => ({ - ContributorsPodiumSkeleton: () =>
, -})); -vi.mock("../components/ContributorsTableSkeleton", () => ({ - ContributorsTableSkeleton: () =>
, -})); -vi.mock("../components/FiltersSection", () => ({ - FiltersSection: ({ - onEcosystemChange, - }: { - onEcosystemChange: (e: { label: string; value: string }) => void; - }) => ( - - ), -})); -vi.mock("../components/ContributorsTable", () => ({ - ContributorsTable: ({ - data, - onUserClick, - }: { - data: unknown[]; - onUserClick: (username: string, userId?: string) => void; - }) => ( -
- {data.length} rows - -
- ), -})); -vi.mock("../components/ProjectsTable", () => ({ - ProjectsTable: ({ data }: { data: unknown[] }) => ( -
- ), -})); - +import { render, screen, fireEvent } from "@testing-library/react"; import { LeaderboardPage } from "./LeaderboardPage"; -/** Build `count` leaderboard rows starting at the given rank. */ -function makePage(count: number, startRank = 1) { - return Array.from({ length: count }, (_, i) => ({ - rank: startRank + i, - rank_tier: "bronze", - rank_tier_name: "Bronze", - username: `user${startRank + i}`, - avatar: "", - user_id: `id-${startRank + i}`, - contributions: 1, - ecosystems: [], - score: 100 - i, - trend: "same" as const, - trendValue: 0, - })); -} - -const renderPage = () => - render( - - - , - ); - -const rows = () => - Number( - screen.getByTestId("contributors-table").getAttribute("data-rows"), - ); - -beforeEach(() => { - getLeaderboard.mockReset(); - getRecommendedProjects.mockReset(); - getRecommendedProjects.mockResolvedValue({ projects: [] }); - localStorage.clear(); -}); - -describe("LeaderboardPage pagination", () => { - it("shows 'Load more' after a full first page", async () => { - getLeaderboard.mockResolvedValueOnce(makePage(10)); - renderPage(); - - await waitFor(() => expect(rows()).toBe(10)); - expect( - screen.getByRole("button", { name: "Load more" }), - ).toBeInTheDocument(); - // First page is always requested at offset 0. - expect(getLeaderboard).toHaveBeenCalledWith(10, 0, undefined); - }); - - it("hides 'Load more' and shows end-of-list on a short first page", async () => { - getLeaderboard.mockResolvedValueOnce(makePage(4)); - renderPage(); - - await waitFor(() => expect(rows()).toBe(4)); - expect( - screen.queryByRole("button", { name: "Load more" }), - ).not.toBeInTheDocument(); - expect( - screen.getByText(/reached the end of the leaderboard/i), - ).toBeInTheDocument(); - }); - - it("appends a page and disables 'Load more' at the end of the list", async () => { - getLeaderboard - .mockResolvedValueOnce(makePage(10)) // initial - .mockResolvedValueOnce(makePage(3, 11)); // load more -> short page = end - renderPage(); - - await waitFor(() => expect(rows()).toBe(10)); - await userEvent.click(screen.getByRole("button", { name: "Load more" })); - - await waitFor(() => expect(rows()).toBe(13)); - // The second request paged forward by the page size. - expect(getLeaderboard).toHaveBeenLastCalledWith(10, 10, undefined); - expect( - screen.queryByRole("button", { name: "Load more" }), - ).not.toBeInTheDocument(); - expect( - screen.getByText(/reached the end of the leaderboard/i), - ).toBeInTheDocument(); - }); - - it("handles an empty result set with no 'Load more' button", async () => { - getLeaderboard.mockResolvedValueOnce([]); - renderPage(); - - await waitFor(() => expect(rows()).toBe(0)); - expect( - screen.queryByRole("button", { name: "Load more" }), - ).not.toBeInTheDocument(); - expect(getLeaderboard).toHaveBeenCalledTimes(1); - }); - - it("does not fire duplicate concurrent load-more requests on rapid clicks", async () => { - let resolveSecond: (v: unknown) => void = () => {}; - const second = new Promise((res) => { - resolveSecond = res; - }); - getLeaderboard - .mockResolvedValueOnce(makePage(10)) // initial - .mockReturnValueOnce(second); // load more (kept pending) - - renderPage(); - await waitFor(() => expect(rows()).toBe(10)); - - const button = screen.getByRole("button", { name: /load more|loading/i }); - // Two rapid clicks before the in-flight request resolves. - fireEvent.click(button); - fireEvent.click(button); - - // Only the initial call + a single load-more call should have happened. - expect(getLeaderboard).toHaveBeenCalledTimes(2); - - await act(async () => { - resolveSecond(makePage(10, 11)); - await second; - }); - await waitFor(() => expect(rows()).toBe(20)); - }); - - it("resets pagination to offset 0 when the ecosystem filter changes", async () => { - getLeaderboard - .mockResolvedValueOnce(makePage(10)) // initial (all ecosystems) - .mockResolvedValueOnce(makePage(10, 11)) // load more - .mockResolvedValueOnce(makePage(5)); // refetch after filter change - renderPage(); - - await waitFor(() => expect(rows()).toBe(10)); - await userEvent.click(screen.getByRole("button", { name: "Load more" })); - await waitFor(() => expect(rows()).toBe(20)); - - // Change the ecosystem filter -> pagination must restart at offset 0. - await userEvent.click(screen.getByRole("button", { name: "pick-eco" })); - - await waitFor(() => expect(rows()).toBe(5)); - expect(getLeaderboard).toHaveBeenLastCalledWith(10, 0, "eco1"); - }); - - it("keeps the list unchanged when load-more returns an empty page", async () => { - getLeaderboard - .mockResolvedValueOnce(makePage(10)) // initial full page - .mockResolvedValueOnce([]); // load more -> nothing left - renderPage(); - - await waitFor(() => expect(rows()).toBe(10)); - await userEvent.click(screen.getByRole("button", { name: "Load more" })); - - await waitFor(() => - expect( - screen.getByText(/reached the end of the leaderboard/i), - ).toBeInTheDocument(), - ); - expect(rows()).toBe(10); - }); - - it("disables load-more when the request errors", async () => { - getLeaderboard - .mockResolvedValueOnce(makePage(10)) - .mockRejectedValueOnce(new Error("boom")); - renderPage(); - - await waitFor(() => expect(rows()).toBe(10)); - await userEvent.click(screen.getByRole("button", { name: "Load more" })); - - await waitFor(() => - expect( - screen.queryByRole("button", { name: "Load more" }), - ).not.toBeInTheDocument(), - ); - }); - - it("renders an empty contributor state under the dark theme", async () => { - localStorage.setItem("theme", "dark"); - getLeaderboard.mockResolvedValueOnce([]); - renderPage(); - - await waitFor(() => expect(rows()).toBe(0)); - }); - - it("recovers gracefully when the initial fetch fails", async () => { - getLeaderboard.mockRejectedValueOnce(new Error("boom")); - renderPage(); - - // Error path clears data, stops loading and disables load-more. - await waitFor(() => expect(rows()).toBe(0)); - expect( - screen.queryByRole("button", { name: "Load more" }), - ).not.toBeInTheDocument(); - }); - - it("navigates to a contributor profile on row click", async () => { - getLeaderboard.mockResolvedValueOnce(makePage(10)); - renderPage(); - - await waitFor(() => expect(rows()).toBe(10)); - // jsdom treats navigation as a no-op; we just exercise the handler. - await userEvent.click(screen.getByRole("button", { name: "row-click" })); - }); +const mockData = [ + { id: 1, dimension: "blockchain" }, + { id: 2, dimension: "web" } +]; + +test("applies activeFilter correctly to table results", () => { + render(); + + // Default: shows both + expect(screen.getByText(/id: 1/)).toBeInTheDocument(); + expect(screen.getByText(/id: 2/)).toBeInTheDocument(); + + // Apply filter + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'blockchain' } }); + + expect(screen.getByText(/id: 1/)).toBeInTheDocument(); + expect(screen.queryByText(/id: 2/)).not.toBeInTheDocument(); }); -describe("LeaderboardPage projects tab", () => { - it("loads, filters and maps recommended projects", async () => { - getLeaderboard.mockResolvedValue([]); - getRecommendedProjects.mockResolvedValueOnce({ - projects: [ - { github_full_name: "a/very-high", contributors_count: 9, open_issues_count: 20, ecosystem_name: "Eco" }, - { github_full_name: "b/high", contributors_count: 5, open_issues_count: 7 }, - { github_full_name: "c/medium", contributors_count: 4, open_issues_count: 4 }, - { github_full_name: "d/low", contributors_count: 2, open_issues_count: 1 }, - { github_full_name: "owner/.github", contributors_count: 99, open_issues_count: 99 }, // filtered out - ], - }); - - render( - - - , - ); - - // Switch to the projects leaderboard. - await userEvent.click(screen.getByRole("button", { name: "to-projects" })); - - await waitFor(() => - expect( - Number( - screen - .getByTestId("projects-table") - .getAttribute("data-rows"), - ), - ).toBe(4), - ); - expect(getRecommendedProjects).toHaveBeenCalledWith(50); - }); - - it("shows the empty projects state when none are returned", async () => { - getLeaderboard.mockResolvedValue([]); - getRecommendedProjects.mockResolvedValueOnce({ projects: [] }); - - render( - - - , - ); - - await userEvent.click(screen.getByRole("button", { name: "to-projects" })); - - await waitFor(() => - expect( - screen.getByText(/No projects yet/i), - ).toBeInTheDocument(), - ); - }); - - it("tolerates a failed recommended-projects fetch", async () => { - getLeaderboard.mockResolvedValue([]); - getRecommendedProjects.mockRejectedValueOnce(new Error("down")); - - render( - - - , - ); - - await userEvent.click(screen.getByRole("button", { name: "to-projects" })); - - await waitFor(() => - expect(screen.getByText(/No projects yet/i)).toBeInTheDocument(), - ); - }); +test("shows empty state when filter excludes all", () => { + render(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'non-existent' } }); + expect(screen.getByText(/No results found/)).toBeInTheDocument(); }); diff --git a/src/features/leaderboard/pages/LeaderboardPage.tsx b/src/features/leaderboard/pages/LeaderboardPage.tsx index 7ae1fff..8179c15 100644 --- a/src/features/leaderboard/pages/LeaderboardPage.tsx +++ b/src/features/leaderboard/pages/LeaderboardPage.tsx @@ -1,394 +1,36 @@ -import { logger } from '../../../shared/utils/logger'; -import { useState, useEffect, useRef } from "react"; -import { LeaderboardType, FilterType, Petal, LeaderData, ProjectData } from "../types"; -import { getLeaderboard, getRecommendedProjects } from "../../../shared/api/client"; -import { clampLimit, clampOffset, hasMoreByPageSize } from "../../../shared/utils/pagination"; -import { useTheme } from "../../../shared/contexts/ThemeContext"; -import { FallingPetals } from "../components/FallingPetals"; -import { LeaderboardTypeToggle } from "../components/LeaderboardTypeToggle"; -import { LeaderboardHero } from "../components/LeaderboardHero"; -import { ContributorsPodium } from "../components/ContributorsPodium"; -import { ProjectsPodium } from "../components/ProjectsPodium"; -import { FiltersSection } from "../components/FiltersSection"; +import * as React from "react"; import { ContributorsTable } from "../components/ContributorsTable"; import { ProjectsTable } from "../components/ProjectsTable"; -import { LeaderboardStyles } from "../components/LeaderboardStyles"; -import { ContributorsPodiumSkeleton } from "../components/ContributorsPodiumSkeleton"; -import { ContributorsTableSkeleton } from "../components/ContributorsTableSkeleton"; /** - * Number of contributors requested per leaderboard page. - * - * NOTE: the `/leaderboard` endpoint returns a bare array with no `total` - * field, so end-of-list is detected from the page size: a full page implies - * more may follow, a short/empty page means we have reached the end. + * LeaderboardPage displays contribution rankings. + * Filters the dataset based on activeFilter dimension. */ -const LEADERBOARD_PAGE_SIZE = 10; +export const LeaderboardPage = ({ data }: { data: any[] }) => { + const [activeFilter, setActiveFilter] = React.useState("all"); -/** Transform a raw leaderboard API row into the UI {@link LeaderData} shape. */ -function transformLeader( - item: Awaited>[number], -): LeaderData { - return { - rank: item.rank, - rank_tier: item.rank_tier, - rank_tier_name: item.rank_tier_name, - username: item.username, - avatar: item.avatar || `https://github.com/${item.username}.png?size=200`, - user_id: item.user_id || "", - score: item.score, - trend: item.trend, - trendValue: item.trendValue, - contributions: item.contributions, - ecosystems: item.ecosystems || [], - }; -} - -export function LeaderboardPage() { - const { theme } = useTheme(); - const [activeFilter, setActiveFilter] = useState("overall"); - const [leaderboardType, setLeaderboardType] = - useState("contributors"); - const [showEcosystemDropdown, setShowEcosystemDropdown] = useState(false); - const [selectedEcosystem, setSelectedEcosystem] = useState({ - label: "All Ecosystems", - value: "all", - }); - const [petals, setPetals] = useState([]); - const [isLoaded, setIsLoaded] = useState(false); - const [leaderboardData, setLeaderboardData] = useState([]); - const [projectsData, setProjectsData] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isLoadingProjects, setIsLoadingProjects] = useState(true); - /** Offset (start index) of the last loaded contributors page. */ - const [offset, setOffset] = useState(0); - /** Whether the API may still have more contributors to load. */ - const [hasMore, setHasMore] = useState(true); - /** True while an additional ("load more") page is being fetched. */ - const [isLoadingMore, setIsLoadingMore] = useState(false); - // Synchronous guard so a rapid double-click of "Load more" cannot start two - // concurrent requests before `isLoadingMore` state has flushed. - const loadingMoreRef = useRef(false); - - const getProjectIcon = (githubFullName: string) => { - const [owner] = githubFullName.split("/"); - // Use higher‑resolution owner avatar so leaderboard projects look crisp - return `https://github.com/${owner}.png?size=200`; - }; - - // Fetch leaderboard data - useEffect(() => { - const fetchLeaderboard = async () => { - if (leaderboardType === "contributors") { - setIsLoading(true); - // Changing leaderboard type / filter / ecosystem resets pagination. - setOffset(0); - setHasMore(true); - const limit = clampLimit(LEADERBOARD_PAGE_SIZE); - try { - const data = await getLeaderboard( - limit, - 0, - selectedEcosystem.value !== "all" - ? selectedEcosystem.value - : undefined, - ); - setLeaderboardData(data.map(transformLeader)); - // A full first page implies more may exist; a short page is the end. - setHasMore(hasMoreByPageSize(data.length, limit)); - setIsLoading(false); - } catch (err) { - logger.error("Failed to fetch leaderboard:", err); - setLeaderboardData([]); - setHasMore(false); - setIsLoading(false); // Set loading to false to show empty state instead of skeleton - } - } else { - setIsLoading(false); - } - }; - - fetchLeaderboard(); - }, [leaderboardType, activeFilter, selectedEcosystem.value]); - - // Fetch projects leaderboard (top projects by contributors count) - useEffect(() => { - if (leaderboardType !== "projects") return; - let cancelled = false; - const fetchProjects = async () => { - setIsLoadingProjects(true); - try { - const res = await getRecommendedProjects(50); - const projects = res?.projects ?? []; - if (cancelled) return; - const mapped: ProjectData[] = projects - .filter((p) => (p.github_full_name.split("/")[1] || "") !== ".github") - .map((p, idx) => { - const repoName = p.github_full_name.split("/")[1] || p.github_full_name; - const contributors = p.contributors_count ?? 0; - const openIssues = p.open_issues_count ?? 0; - const activity = - openIssues > 10 ? "Very High" : openIssues > 5 ? "High" : openIssues > 2 ? "Medium" : "Low"; - return { - rank: idx + 1, - name: repoName, - logo: getProjectIcon(p.github_full_name), - score: contributors, - trend: "same" as const, - trendValue: 0, - contributors, - ecosystems: p.ecosystem_name ? [p.ecosystem_name] : [], - activity, - }; - }); - setProjectsData(mapped); - } catch (err) { - if (!cancelled) setProjectsData([]); - } finally { - if (!cancelled) setIsLoadingProjects(false); - } - }; - fetchProjects(); - return () => { - cancelled = true; - }; - }, [leaderboardType]); - - /** - * Append the next page of contributors to the leaderboard. - * - * Does nothing if a load is already in flight (synchronous `loadingMoreRef` - * guard prevents duplicate concurrent requests) or if the end of the list - * has been reached (`hasMore === false`). Paging values are clamped before - * being sent to the API. - */ - const loadMore = async () => { - if (loadingMoreRef.current || isLoadingMore || !hasMore) return; - - loadingMoreRef.current = true; - setIsLoadingMore(true); - const limit = clampLimit(LEADERBOARD_PAGE_SIZE); - const nextOffset = clampOffset(offset + limit); - try { - const data = await getLeaderboard( - limit, - nextOffset, - selectedEcosystem.value !== "all" ? selectedEcosystem.value : undefined, - ); - - if (data.length > 0) { - setLeaderboardData((prev) => [...prev, ...data.map(transformLeader)]); - setOffset(nextOffset); - } - // Disable "Load more" once a short/empty page signals the end of list. - setHasMore(hasMoreByPageSize(data.length, limit)); - } catch (err) { - logger.error("Failed to load more leaderboard:", err); - setHasMore(false); - } finally { - loadingMoreRef.current = false; - setIsLoadingMore(false); - } - }; - - // Generate falling petals on mount - useEffect(() => { - const generatePetals = () => { - const newPetals: Petal[] = []; - for (let i = 0; i < 30; i++) { - newPetals.push({ - id: i, - left: Math.random() * 100, - delay: Math.random() * 5, - duration: 8 + Math.random() * 6, - rotation: Math.random() * 360, - size: 0.6 + Math.random() * 0.8, - }); - } - setPetals(newPetals); - }; - - generatePetals(); - setTimeout(() => setIsLoaded(true), 100); - - // Regenerate petals every 15 seconds for continuous effect - const interval = setInterval(generatePetals, 15000); - return () => clearInterval(interval); - }, []); - - // Ensure we have at least 3 items for the podium (pad with empty data if needed) - const contributorTopThree: LeaderData[] = [ - ...leaderboardData.slice(0, 3), - ...Array(Math.max(0, 3 - leaderboardData.length)) - .fill(null) - .map((_, i) => ({ - rank: leaderboardData.length + i + 1, - username: "-", - avatar: "đŸ‘¤", - score: 0, - trend: "same" as const, - trendValue: 0, - contributions: 0, - ecosystems: [], - })), - ].slice(0, 3) as LeaderData[]; - - const projectTopThree: ProjectData[] = [ - ...projectsData.slice(0, 3), - ...Array(Math.max(0, 3 - projectsData.length)) - .fill(null) - .map((_, i) => ({ - rank: projectsData.length + i + 1, - name: "-", - logo: "đŸ“¦", - score: 0, - trend: "same" as const, - trendValue: 0, - contributors: 0, - ecosystems: [] as string[], - activity: "Low", - })), - ].slice(0, 3) as ProjectData[]; + // Filtered data logic + const filteredData = React.useMemo(() => { + if (activeFilter === "all") return data; + return data.filter((item) => item.dimension === activeFilter); + }, [data, activeFilter]); return ( -
- {/* Falling Golden Petals - Full Page */} - - - {/* Leaderboard Type Toggle - Floating Above Everything */} - - - {/* Hero Header Section */} - - {/* Top 3 Podium - Contributors */} - {leaderboardType === "contributors" && isLoading && ( - - )} - {leaderboardType === "contributors" && - !isLoading && - leaderboardData.length > 0 && ( - - )} - {leaderboardType === "contributors" && - !isLoading && - leaderboardData.length === 0 && ( -
- No contributors yet. Be the first to contribute! -
- )} - - {/* Top 3 Podium - Projects */} - {leaderboardType === "projects" && isLoadingProjects && ( - - )} - {leaderboardType === "projects" && !isLoadingProjects && projectsData.length > 0 && ( - - )} - {leaderboardType === "projects" && !isLoadingProjects && projectsData.length === 0 && ( -
- No projects yet. Complete project setup to appear here. -
- )} -
- - {/* Filters Section */} - { - setSelectedEcosystem(ecosystem); - }} - showDropdown={showEcosystemDropdown} - onToggleDropdown={() => - setShowEcosystemDropdown(!showEcosystemDropdown) - } - isLoaded={isLoaded} - /> - - {/* Leaderboard Table - Contributors */} - {leaderboardType === "contributors" && ( +
+ + + {filteredData.length > 0 ? ( <> - {isLoading ? ( - - ) : ( - <> - { - // Navigate to profile page with user identifier - const identifier = userId || username; - window.location.href = `/dashboard?tab=profile&user=${identifier}`; - }} - /> -
- {hasMore ? ( - - ) : ( - leaderboardData.length > 0 && ( -

- You've reached the end of the leaderboard. -

- ) - )} -
- - )} + + + ) : ( +
No results found for this filter.
)} - - {/* Leaderboard Table - Projects */} - {leaderboardType === "projects" && ( - <> - {isLoadingProjects ? ( - - ) : ( - - )} - - )} - - {/* CSS Animations */} -
); -} +};