diff --git a/frontend/app/components/modules/leaderboards/README.md b/frontend/app/components/modules/leaderboards/README.md new file mode 100644 index 000000000..e75f56a79 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/README.md @@ -0,0 +1,128 @@ +# Leaderboards Component + +This directory contains the leaderboard components for displaying various project rankings and metrics. + +## Components + +### LeaderboardDetail (`components/views/leaderboard-detail.vue`) + +A comprehensive leaderboard view component that displays a ranked list of projects with their metrics and trends. + +#### Features + +- **Three-column layout**: Back button, main content (720px), and sidebar navigation (260px) +- **Search functionality**: Filter projects by name +- **Trend indicators**: Show up/down/neutral trends with percentages and change values +- **Responsive design**: Fixed widths for consistent layout +- **Dynamic routing**: Navigate between different leaderboards +- **Share functionality**: Share leaderboard via native share API or clipboard + +#### Props + +- `leaderboardKey` (required): String - The key identifying which leaderboard to display +- `items`: Array - List of leaderboard items (default: []) +- `highlightedIndex`: Number - Index of item to highlight (default: -1) + +#### LeaderboardItem Interface + +```typescript +interface LeaderboardItem { + id: string; + name: string; + logo?: string; + value: number; + trend?: LeaderboardTrend; +} + +interface LeaderboardTrend { + direction: 'up' | 'down' | 'neutral'; + percentage: number; + change: number; +} +``` + +#### Usage Example + +```vue + + + +``` + +## Configuration + +### Leaderboard Configs (`config/`) + +Each leaderboard has a configuration file that defines: + +- `key`: Unique identifier +- `name`: Display name +- `icon`: FontAwesome icon name +- `dataDisplay`: Component for rendering the metric +- `sort`: Sort order +- `columnLabel`: Label for the metric column + +#### Example Config + +```typescript +export const mostActiveContributorsConfig: LeaderboardConfig = { + key: 'most-active-contributors', + name: 'Most Active Contributors', + icon: 'people-group', + dataDisplay: NumericDataDisplay, + sort: 'mostActiveContributors_DESC', + columnLabel: 'Contributors (12M)', +}; +``` + +## Data Display Components + +Located in `components/data-displays/`, these components format the metric values: + +- `numeric.vue`: Formats numbers with proper thousand separators +- `time-duration.vue`: Formats time durations + +## Routing + +The leaderboard detail view is accessible via: + +``` +/leaderboards/[key] +``` + +Where `[key]` is the leaderboard configuration key (e.g., `most-active-contributors`). + +## Styling + +The component uses Tailwind CSS with the project's design system: + +- Colors: `neutral-*`, `positive-600`, `negative-600`, `brand-500` +- Fonts: Primary font for body text, `font-secondary` (Roboto Slab) for headings +- Spacing: Consistent with project standards (gap-2, gap-3, etc.) + +## Navigation + +The sidebar navigation displays all available leaderboards and highlights the active one. Clicking on a leaderboard navigates to its detail page. diff --git a/frontend/app/components/modules/leaderboards/components/data-displays/numeric.vue b/frontend/app/components/modules/leaderboards/components/data-displays/numeric.vue new file mode 100644 index 000000000..63f6debad --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/data-displays/numeric.vue @@ -0,0 +1,73 @@ + + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/data-displays/time-duration.vue b/frontend/app/components/modules/leaderboards/components/data-displays/time-duration.vue new file mode 100644 index 000000000..63f2b70c2 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/data-displays/time-duration.vue @@ -0,0 +1,59 @@ + + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/data-displays/trend-display.vue b/frontend/app/components/modules/leaderboards/components/data-displays/trend-display.vue new file mode 100644 index 000000000..394807ff2 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/data-displays/trend-display.vue @@ -0,0 +1,111 @@ + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/sections/leaderboard-card.vue b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-card.vue new file mode 100644 index 000000000..956fd025a --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-card.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/sections/leaderboard-detail-header.vue b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-detail-header.vue new file mode 100644 index 000000000..39084b98f --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-detail-header.vue @@ -0,0 +1,182 @@ + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/sections/leaderboard-mobile-nav.vue b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-mobile-nav.vue new file mode 100644 index 000000000..c9fe78e57 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-mobile-nav.vue @@ -0,0 +1,93 @@ + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/sections/leaderboard-search-results.vue b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-search-results.vue new file mode 100644 index 000000000..5f4f428d7 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-search-results.vue @@ -0,0 +1,73 @@ + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/sections/leaderboard-search.vue b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-search.vue new file mode 100644 index 000000000..75c4e2468 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-search.vue @@ -0,0 +1,180 @@ + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/sections/leaderboard-sidebar.vue b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-sidebar.vue new file mode 100644 index 000000000..4db6a44e2 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-sidebar.vue @@ -0,0 +1,46 @@ + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/sections/leaderboard-table.vue b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-table.vue new file mode 100644 index 000000000..d6cd73296 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/sections/leaderboard-table.vue @@ -0,0 +1,90 @@ + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/sections/table-header.vue b/frontend/app/components/modules/leaderboards/components/sections/table-header.vue new file mode 100644 index 000000000..6cc66ae02 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/sections/table-header.vue @@ -0,0 +1,54 @@ + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/sections/table-row.vue b/frontend/app/components/modules/leaderboards/components/sections/table-row.vue new file mode 100644 index 000000000..a9452175c --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/sections/table-row.vue @@ -0,0 +1,90 @@ + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/views/leaderboard-detail.vue b/frontend/app/components/modules/leaderboards/components/views/leaderboard-detail.vue new file mode 100644 index 000000000..7708e9fe1 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/views/leaderboard-detail.vue @@ -0,0 +1,234 @@ + + + + + + diff --git a/frontend/app/components/modules/leaderboards/components/views/leaderboard-landing.vue b/frontend/app/components/modules/leaderboards/components/views/leaderboard-landing.vue new file mode 100644 index 000000000..08c658072 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/components/views/leaderboard-landing.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/frontend/app/components/modules/leaderboards/config/codebase-size.config.ts b/frontend/app/components/modules/leaderboards/config/codebase-size.config.ts new file mode 100644 index 000000000..615ababcf --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/codebase-size.config.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import NumericDataDisplay from '../components/data-displays/numeric.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const codebaseSizeConfig: LeaderboardConfig = { + key: 'codebase-size', + name: 'Codebase size', + description: + 'These projects maintain the largest codebases measured by total source lines of code.', + icon: 'laptop-code', + dataDisplay: NumericDataDisplay, + columnLabel: 'Lines of code', + hideTrend: true, + dataType: 'integer', +}; diff --git a/frontend/app/components/modules/leaderboards/config/commit-activity.config.ts b/frontend/app/components/modules/leaderboards/config/commit-activity.config.ts new file mode 100644 index 000000000..a8ce1b056 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/commit-activity.config.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import NumericDataDisplay from '../components/data-displays/numeric.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const commitActivityConfig: LeaderboardConfig = { + key: 'commit-activity', + name: 'Commit activity', + description: + 'These projects recorded the most commits during the past 12 months, showing high development momentum.', + icon: 'code-commit', + dataDisplay: NumericDataDisplay, + columnLabel: 'Commits (12m)', + dataType: 'integer', +}; diff --git a/frontend/app/components/modules/leaderboards/config/fastest-mergers.config.ts b/frontend/app/components/modules/leaderboards/config/fastest-mergers.config.ts new file mode 100644 index 000000000..359f84def --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/fastest-mergers.config.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import TimeDurationDisplay from '../components/data-displays/time-duration.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const fastestMergersConfig: LeaderboardConfig = { + key: 'fastest-mergers', + name: 'Fastest mergers', + description: 'These projects merge pull requests the fastest over the past 12 months.', + icon: 'code-merge', + dataDisplay: TimeDurationDisplay, + columnLabel: 'Median time to merge (12m)', + dataType: 'duration', +}; diff --git a/frontend/app/components/modules/leaderboards/config/fastest-responders.config.ts b/frontend/app/components/modules/leaderboards/config/fastest-responders.config.ts new file mode 100644 index 000000000..e038e53ca --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/fastest-responders.config.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import TimeDurationDisplay from '../components/data-displays/time-duration.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const fastestRespondersConfig: LeaderboardConfig = { + key: 'fastest-responders', + name: 'Fastest responders', + description: + 'These projects achieve the shortest median time to first response on issues over the past 12 months.', + icon: 'comment-check', + dataDisplay: TimeDurationDisplay, + columnLabel: 'Median time to 1st response (12m)', + dataType: 'duration', +}; diff --git a/frontend/app/components/modules/leaderboards/config/focused-teams.config.ts b/frontend/app/components/modules/leaderboards/config/focused-teams.config.ts new file mode 100644 index 000000000..2fd746ecb --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/focused-teams.config.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import NumericDataDisplay from '../components/data-displays/numeric.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const focusedTeamsConfig: LeaderboardConfig = { + key: 'focused-teams', + name: 'Most focused teams', + description: 'These projects show the highest productivity per contributor.', + icon: 'bullseye-arrow', + dataDisplay: NumericDataDisplay, + columnLabel: 'Avg. commits per author', + columnTooltip: 'For projects with 10+ authors', + dataType: 'integer', +}; diff --git a/frontend/app/components/modules/leaderboards/config/index.config.ts b/frontend/app/components/modules/leaderboards/config/index.config.ts new file mode 100644 index 000000000..ff8e3f032 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/index.config.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import type { LeaderboardConfig } from './types/leaderboard.types'; +import { mostActiveContributorsConfig } from './most-active-contributors.config'; +import { mostActiveOrganizationsConfig } from './most-active-organizations.config'; +// import { longestRunningConfig } from './longest-running.config'; +import { commitActivityConfig } from './commit-activity.config'; +import { focusedTeamsConfig } from './focused-teams.config'; +import { smallTeamsMassiveOutputConfig } from './small-teams-massive-output.config'; +import { codebaseSizeConfig } from './codebase-size.config'; +import { fastestRespondersConfig } from './fastest-responders.config'; +import { fastestMergersConfig } from './fastest-mergers.config'; +import { resolutionRateConfig } from './resolution-rate.config'; +// import { top100ProjectsConfig } from './top-100-projects.config'; +// import { top100ContributorsConfig } from './top-100-contributors.config'; +// import { top100OrganizationsConfig } from './top-100-organizations.config'; + +const leaderboardConfigs: LeaderboardConfig[] = [ + // top100ProjectsConfig, + // top100ContributorsConfig, + // top100OrganizationsConfig, + mostActiveContributorsConfig, + mostActiveOrganizationsConfig, + commitActivityConfig, + // longestRunningConfig, + codebaseSizeConfig, + fastestRespondersConfig, + fastestMergersConfig, + focusedTeamsConfig, + resolutionRateConfig, + smallTeamsMassiveOutputConfig, +]; + +export default leaderboardConfigs; diff --git a/frontend/app/components/modules/leaderboards/config/longest-running.config.ts b/frontend/app/components/modules/leaderboards/config/longest-running.config.ts new file mode 100644 index 000000000..14a35a836 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/longest-running.config.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import TimeDurationDisplay from '../components/data-displays/time-duration.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const longestRunningConfig: LeaderboardConfig = { + key: 'longest-running', + name: 'Longest running', + description: 'These projects have been maintained the longest.', + icon: 'calendar-range', + dataDisplay: TimeDurationDisplay, + columnLabel: 'Time since first commit', + columnTooltip: 'For projects with at least 1 commit in the last 12 months', + hideTrend: true, + dataType: 'timestamp', +}; diff --git a/frontend/app/components/modules/leaderboards/config/most-active-contributors.config.ts b/frontend/app/components/modules/leaderboards/config/most-active-contributors.config.ts new file mode 100644 index 000000000..dc113305f --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/most-active-contributors.config.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import NumericDataDisplay from '../components/data-displays/numeric.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const mostActiveContributorsConfig: LeaderboardConfig = { + key: 'active-contributors', + name: 'Most active contributors', + description: + 'These projects attracted the highest number of unique contributors over the past 12 months.', + icon: 'people-group', + dataDisplay: NumericDataDisplay, + columnLabel: 'Contributors (12m)', + dataType: 'integer', +}; diff --git a/frontend/app/components/modules/leaderboards/config/most-active-organizations.config.ts b/frontend/app/components/modules/leaderboards/config/most-active-organizations.config.ts new file mode 100644 index 000000000..c6698ce7e --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/most-active-organizations.config.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import NumericDataDisplay from '../components/data-displays/numeric.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const mostActiveOrganizationsConfig: LeaderboardConfig = { + key: 'active-organizations', + name: 'Most active organizations', + description: + 'These projects brought together the largest number of distinct contributing organizations in the past 12 months.', + icon: 'buildings', + dataDisplay: NumericDataDisplay, + columnLabel: 'Organizations (12m)', + dataType: 'integer', +}; diff --git a/frontend/app/components/modules/leaderboards/config/resolution-rate.config.ts b/frontend/app/components/modules/leaderboards/config/resolution-rate.config.ts new file mode 100644 index 000000000..57d122c7b --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/resolution-rate.config.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import NumericDataDisplay from '../components/data-displays/numeric.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const resolutionRateConfig: LeaderboardConfig = { + key: 'resolution-rate', + name: 'Highest resolution rate', + description: + 'These projects keep development flowing, with most pull requests merged relative to issues opened.', + icon: 'rocket-launch', + dataDisplay: NumericDataDisplay, + columnLabel: 'PR/Issue ratio', + dataType: 'float', + decimals: 2, +}; diff --git a/frontend/app/components/modules/leaderboards/config/small-teams-massive-output.config.ts b/frontend/app/components/modules/leaderboards/config/small-teams-massive-output.config.ts new file mode 100644 index 000000000..c5991d5d8 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/small-teams-massive-output.config.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import NumericDataDisplay from '../components/data-displays/numeric.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const smallTeamsMassiveOutputConfig: LeaderboardConfig = { + key: 'small-teams-massive-output', + name: 'Small teams, massive output ', + description: + 'These projects demonstrate exceptional productivity, achieving the highest commit volumes with 50 or fewer contributors.', + icon: 'arrow-up-big-small', + dataDisplay: NumericDataDisplay, + columnLabel: 'Commits', + columnTooltip: 'For projects with ≤50 contributors', + dataType: 'integer', +}; diff --git a/frontend/app/components/modules/leaderboards/config/top-100-contributors.config.ts b/frontend/app/components/modules/leaderboards/config/top-100-contributors.config.ts new file mode 100644 index 000000000..0abf28bb5 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/top-100-contributors.config.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import NumericDataDisplay from '../components/data-displays/numeric.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const top100ContributorsConfig: LeaderboardConfig = { + key: 'top-100-contributors', + name: 'Top 100 contributors', + description: + 'Developers ranked by volume of contributions over the last 10 years, highlighting the most active and influential individuals.', + icon: 'head-side-gear', + dataDisplay: NumericDataDisplay, + columnLabel: 'Contributions (10y)', + dataType: 'integer', +}; diff --git a/frontend/app/components/modules/leaderboards/config/top-100-organizations.config.ts b/frontend/app/components/modules/leaderboards/config/top-100-organizations.config.ts new file mode 100644 index 000000000..bd391a96d --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/top-100-organizations.config.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import NumericDataDisplay from '../components/data-displays/numeric.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const top100OrganizationsConfig: LeaderboardConfig = { + key: 'top-100-organizations', + name: 'Top 100 organizations', + description: + 'Most influential organizations based on the total number of contributions made over the last 10 years.', + icon: 'chart-network', + dataDisplay: NumericDataDisplay, + columnLabel: 'Contributors (10y)', + dataType: 'integer', +}; diff --git a/frontend/app/components/modules/leaderboards/config/top-100-projects.config.ts b/frontend/app/components/modules/leaderboards/config/top-100-projects.config.ts new file mode 100644 index 000000000..76da7b42f --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/top-100-projects.config.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT + +import NumericDataDisplay from '../components/data-displays/numeric.vue'; +import type { LeaderboardConfig } from './types/leaderboard.types'; + +export const top100ProjectsConfig: LeaderboardConfig = { + key: 'top-100-projects', + name: 'Top 100 projects', + description: 'Open source projects ranked by the total number of contributors.', + icon: 'trophy', + dataDisplay: NumericDataDisplay, + columnLabel: 'Contributors', + dataType: 'integer', +}; diff --git a/frontend/app/components/modules/leaderboards/config/types/leaderboard.types.ts b/frontend/app/components/modules/leaderboards/config/types/leaderboard.types.ts new file mode 100644 index 000000000..93323d5cc --- /dev/null +++ b/frontend/app/components/modules/leaderboards/config/types/leaderboard.types.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import type { Component } from 'vue'; +import type { Leaderboard } from '~~/types/leaderboard/leaderboard'; + +export type LeaderboardDataType = 'integer' | 'float' | 'duration' | 'timestamp'; +export interface LeaderboardConfig { + key: string; + name: string; + description: string; + icon: string; + dataDisplay: Component; + columnLabel: string; + columnTooltip?: string; + hideTrend?: boolean; + dataType: LeaderboardDataType; + decimals?: number; +} + +export interface LeaderboardLandingResponse { + data: Leaderboard[]; + page: number; + pageSize: number; +} diff --git a/frontend/app/components/modules/leaderboards/services/leaderboard.api.service.ts b/frontend/app/components/modules/leaderboards/services/leaderboard.api.service.ts new file mode 100644 index 000000000..1058e1400 --- /dev/null +++ b/frontend/app/components/modules/leaderboards/services/leaderboard.api.service.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import { + type QueryFunction, + useInfiniteQuery, + useQuery, + useQueryClient, +} from '@tanstack/vue-query'; +import { type ComputedRef, computed } from 'vue'; +import type { LeaderboardLandingResponse } from '../config/types/leaderboard.types'; +import { TanstackKey } from '~/components/shared/types/tanstack'; +import type { Leaderboard } from '~~/types/leaderboard/leaderboard'; +import type { Pagination } from '~~/types/shared/pagination'; + +export interface LeaderboardDetailQueryParams { + leaderboardType: string; + search?: string; + initialPageSize?: number; +} + +const DEFAULT_PAGE_SIZE = 100; +class LeaderboardApiService { + async prefetchLeaderboardDetails(params: ComputedRef) { + const queryClient = useQueryClient(); + const queryKey = computed(() => [TanstackKey.LEADERBOARD_DETAIL, params.value.leaderboardType]); + + const queryFn = computed>>(() => + this.leaderboardDetailQueryFn(() => ({ + leaderboardType: params.value.leaderboardType, + initialPageSize: params.value.initialPageSize, + search: params.value.search, + })), + ); + + return await queryClient.prefetchInfiniteQuery< + Pagination, + Error, + Pagination, + readonly unknown[], + number + >({ + queryKey, + //@ts-expect-error - TanStack Query type inference issue with Vue + queryFn, + getNextPageParam: this.getNextPageLeaderboardParam, + initialPageParam: 0, + }); + } + getNextPageLeaderboardParam(lastPage: Pagination) { + let nextPage = Number(lastPage.page) + 1; + + // Handle the case where initialPageSize is greater than DEFAULT_PAGE_SIZE + if (lastPage.pageSize > DEFAULT_PAGE_SIZE) { + nextPage = Number(lastPage.pageSize) / DEFAULT_PAGE_SIZE + 1; + } + + const totalPages = Math.ceil(lastPage.total / DEFAULT_PAGE_SIZE); + return nextPage < totalPages ? nextPage : null; + } + + fetchLeaderboardDetails(params: ComputedRef) { + const queryKey = computed(() => [TanstackKey.LEADERBOARD_DETAIL, params.value.leaderboardType]); + + const queryFn = computed>>(() => + this.leaderboardDetailQueryFn(() => ({ + leaderboardType: params.value.leaderboardType, + initialPageSize: params.value.initialPageSize, + search: params.value.search, + })), + ); + + return useInfiniteQuery< + Pagination, + Error, + Pagination, + readonly unknown[], + number + >({ + queryKey, + //@ts-expect-error - TanStack Query type inference issue with Vue + queryFn, + getNextPageParam: this.getNextPageLeaderboardParam, + initialPageParam: 0, + }); + } + + leaderboardDetailQueryFn( + query: () => Record, + ): QueryFunction, readonly unknown[], number> { + const { leaderboardType, initialPageSize, search } = query(); + return async ({ pageParam = 0 }) => { + const pageSize = pageParam === 0 ? (initialPageSize ?? DEFAULT_PAGE_SIZE) : DEFAULT_PAGE_SIZE; + + return await $fetch(`/api/leaderboard/${leaderboardType}`, { + params: { + page: pageParam, + pageSize, + search, + }, + }); + }; + } + + fetchLeaderboardDetailSearch(params: ComputedRef) { + const queryKey = computed(() => [ + TanstackKey.LEADERBOARD_DETAIL_SEARCH, + params.value.leaderboardType, + params.value.search, + ]); + + const queryFn = computed>>(() => + this.leaderboardDetailQueryFn(() => ({ + leaderboardType: params.value.leaderboardType, + initialPageSize: 15, + search: params.value.search, + })), + ); + + return useInfiniteQuery< + Pagination, + Error, + Pagination, + readonly unknown[], + number + >({ + queryKey, + //@ts-expect-error - TanStack Query type inference issue with Vue + queryFn, + getNextPageParam: this.getNextPageLeaderboardParam, + initialPageParam: 0, + enabled: computed(() => !!params.value.search && params.value.search.trim().length > 0), + }); + } + + fetchLeaderboardLanding() { + const queryKey = computed(() => [TanstackKey.LEADERBOARD_INDEX]); + const queryFn = this.leaderboardLandingQueryFn(); + + return useQuery< + LeaderboardLandingResponse, + Error, + LeaderboardLandingResponse, + readonly unknown[] + >({ queryKey, queryFn }); + } + + leaderboardLandingQueryFn(): QueryFunction { + return async () => { + return await $fetch('/api/leaderboard', { + params: { maxRank: 5 }, + }); + }; + } + + groupLeaderboardsByType(leaderboards: Leaderboard[]): Record { + return leaderboards.reduce( + (acc, leaderboard) => { + acc[leaderboard.leaderboardType] = [ + ...(acc[leaderboard.leaderboardType] || []), + leaderboard, + ]; + return acc; + }, + {} as Record, + ); + } +} + +export const LEADERBOARD_API_SERVICE = new LeaderboardApiService(); diff --git a/frontend/app/components/shared/types/routes.ts b/frontend/app/components/shared/types/routes.ts index d0ff12900..bd9e344f9 100644 --- a/frontend/app/components/shared/types/routes.ts +++ b/frontend/app/components/shared/types/routes.ts @@ -4,6 +4,8 @@ export enum LfxRoutes { HOME = '/', EXPLORE = 'index', OPENSOURCEINDEX = 'open-source-index', + LEADERBOARDS = 'leaderboards', + LEADERBOARD = 'leaderboards-key', OPENSOURCEINDEX_GROUP = 'open-source-index-group-slug', OPENSOURCEINDEX_CATEGORY = 'open-source-index-category-slug', COLLECTIONS = 'collection', diff --git a/frontend/app/components/shared/types/tanstack.ts b/frontend/app/components/shared/types/tanstack.ts index 5f975d1df..9b119eec2 100644 --- a/frontend/app/components/shared/types/tanstack.ts +++ b/frontend/app/components/shared/types/tanstack.ts @@ -63,4 +63,9 @@ export enum TanstackKey { TOP_CONTRIBUTORS = 'explore-top-contributors', TOP_ORGANIZATIONS = 'explore-top-organizations', TOP_PROJECTS = 'explore-top-projects', + + // Leaderboards + LEADERBOARD_INDEX = 'leaderboard-index', + LEADERBOARD_DETAIL = 'leaderboard-detail', + LEADERBOARD_DETAIL_SEARCH = 'leaderboard-detail-search', } diff --git a/frontend/app/components/shared/utils/formatter.ts b/frontend/app/components/shared/utils/formatter.ts index 97bb6f073..7a7163874 100644 --- a/frontend/app/components/shared/utils/formatter.ts +++ b/frontend/app/components/shared/utils/formatter.ts @@ -7,7 +7,7 @@ * @returns Formatted string representation of the number */ -import { Duration } from 'luxon'; +import { DateTime, Duration } from 'luxon'; import pluralize from 'pluralize'; import { FormatterUnits } from '~/components/shared/types/formatter.types'; @@ -131,3 +131,60 @@ export const formatSecondsToDuration = ( // Only show decimal for seconds return convertToUnit(value, FormatterUnits.SECONDS, showUnits, roundTo); }; + +export const formatValueToLargestUnitDuration = ( + value: number, + noOfUnits: number = 2, + isDuration?: boolean, +): string => { + let duration; + + if (isDuration) { + // Value is already a duration in seconds - create Duration object directly + duration = Duration.fromObject({ seconds: value }).shiftTo( + 'years', + 'months', + 'days', + 'hours', + 'minutes', + 'seconds', + ); + } else { + // Calculate duration from timestamp to now + const timestamp = DateTime.fromMillis(value); + const now = DateTime.now(); + duration = now.diff(timestamp, ['years', 'months', 'days', 'hours', 'minutes', 'seconds']); + } + + const { years, months, days, hours, minutes, seconds } = duration.toObject(); + + const units: Array<{ value: number; label: string }> = []; + + // Build array of non-zero units + if (years && years > 0) { + units.push({ value: Math.floor(years), label: 'y' }); + } + if (months && months > 0) { + units.push({ value: Math.floor(months), label: 'mo' }); + } + + if (days && days > 0) { + units.push({ value: Math.floor(days), label: 'd' }); + } + + if (hours && hours > 0) { + units.push({ value: Math.floor(hours), label: 'h' }); + } + if (minutes && minutes > 0) { + units.push({ value: Math.floor(minutes), label: 'm' }); + } + if (seconds && seconds > 0) { + units.push({ value: Math.floor(seconds), label: 's' }); + } + + // Return up to 2 units + return units + .slice(0, noOfUnits) + .map((unit) => `${unit.value}${unit.label}`) + .join(' '); +}; diff --git a/frontend/app/components/uikit/button/button.scss b/frontend/app/components/uikit/button/button.scss index 519290138..23416c5ec 100644 --- a/frontend/app/components/uikit/button/button.scss +++ b/frontend/app/components/uikit/button/button.scss @@ -68,6 +68,22 @@ } } + &-ghost { + @apply bg-transparent text-neutral-900 font-semibold; + + &:hover { + @apply bg-neutral-50; + } + + &:active { + @apply bg-transparent; + } + + &:disabled { + @apply text-neutral-400; + } + } + &-large { @apply text-base px-5 py-3; } diff --git a/frontend/app/components/uikit/button/types/button.types.ts b/frontend/app/components/uikit/button/types/button.types.ts index 3a64a192b..11ec2649a 100644 --- a/frontend/app/components/uikit/button/types/button.types.ts +++ b/frontend/app/components/uikit/button/types/button.types.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 The Linux Foundation and each contributor. // SPDX-License-Identifier: MIT -export const buttonTypes = ['primary', 'secondary', 'tertiary', 'transparent'] as const; +export const buttonTypes = ['primary', 'secondary', 'tertiary', 'transparent', 'ghost'] as const; export const buttonSizes = ['small', 'medium', 'large'] as const; export const iconPosition = ['left', 'right'] as const; diff --git a/frontend/app/config/menu/footer.ts b/frontend/app/config/menu/footer.ts index c5d87c888..eda5614a7 100644 --- a/frontend/app/config/menu/footer.ts +++ b/frontend/app/config/menu/footer.ts @@ -24,6 +24,10 @@ export const lfxFooterMenu: FooterMenuSection[] = [ { title: 'LFX Insights', links: [ + { + name: 'Leaderboards', + route: LfxRoutes.LEADERBOARDS, + }, { name: 'Collections', route: LfxRoutes.COLLECTIONS, diff --git a/frontend/app/config/menu/index.ts b/frontend/app/config/menu/index.ts index 0eeda8664..7226adde5 100644 --- a/frontend/app/config/menu/index.ts +++ b/frontend/app/config/menu/index.ts @@ -16,6 +16,11 @@ interface MenuConfig { export const lfxMenu: MenuConfig = { links: [ + { + label: 'Leaderboards', + icon: 'trophy', + route: LfxRoutes.LEADERBOARDS, + }, { label: 'Open Source Index', icon: 'globe', diff --git a/frontend/app/layouts/default.vue b/frontend/app/layouts/default.vue index 12505bb08..ec392f3bf 100644 --- a/frontend/app/layouts/default.vue +++ b/frontend/app/layouts/default.vue @@ -3,7 +3,7 @@ Copyright (c) 2025 The Linux Foundation and each contributor. SPDX-License-Identifier: MIT -->