Files
git-velocity/web/src/views/Leaderboard.vue
T
lukaszraczylo 7ba4d438dd improvements jan2025 (#9)
* feat(scoring): add tests bonus and fix average calculations

- [x] Add CommitsWithTests metric to track commits with test file changes
- [x] Add TestsBonus to score breakdown (15 points per commit with tests)
- [x] Fix AvgTimeToMerge calculation to use count of PRs with valid data
- [x] Fix AvgReviewTime calculation to use count of reviews with valid data
- [x] Fix AvgPRSize calculation to only include merged PRs
- [x] Add trackActivityDay helper to deduplicate activity tracking code
- [x] Track activity days for PR creation, reviews, and issue comments
- [x] Separate issue close tracking from issue open tracking
- [x] Update early bird window from 5am-9am to 6am-9am
- [x] Add time-based multipliers to velocity timeline scoring
- [x] Update GraphQL query to fetch OPEN, MERGED, CLOSED PRs
- [x] Fix PR filtering logic to handle all PR states correctly
- [x] Improve watch handlers in Vue components to prevent double-loading
- [x] Fix formatDuration to handle zero and negative values
- [x] Update scoring documentation to include Tests component

* refactor: use standard library and consolidate constants

- [x] Replace custom contains function with slices.Contains
- [x] Remove duplicate contains function implementations
- [x] Extract magic numbers to named constants in formatters
- [x] Create constants composable for app-wide values
- [x] Add ESLint configuration with browser globals
- [x] Add lint npm scripts to package.json
- [x] Reorder Vue template attributes for consistency
- [x] Remove unused variable in AchievementProgress
- [x] Add pnpm lock file
2026-01-13 11:39:35 +00:00

191 lines
7.3 KiB
Vue

<script setup>
import { ref, inject, computed } from 'vue'
import { RouterLink } from 'vue-router'
import Card from '../components/Card.vue'
import PageHeader from '../components/PageHeader.vue'
import DataTable from '../components/DataTable.vue'
import ContributorRow from '../components/ContributorRow.vue'
import RankBadge from '../components/RankBadge.vue'
import Avatar from '../components/Avatar.vue'
import AchievementBadge from '../components/AchievementBadge.vue'
import { formatNumber } from '../composables/formatters'
import { getHighestTierAchievements } from '../composables/achievements'
const globalData = inject('globalData')
const searchQuery = ref('')
const allContributors = computed(() => globalData.value?.leaderboard || [])
const leaderboard = computed(() => {
if (!searchQuery.value.trim()) return allContributors.value
const query = searchQuery.value.toLowerCase().trim()
return allContributors.value.filter(contributor => {
const name = (contributor.name || '').toLowerCase()
const login = (contributor.login || '').toLowerCase()
return name.includes(query) || login.includes(query)
})
})
const tableColumns = [
{ key: 'rank', label: 'Rank', align: 'left' },
{ key: 'contributor', label: 'Contributor', align: 'left' },
{ key: 'achievements', label: 'Achievements', align: 'left', headerClass: 'hidden md:table-cell' },
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden xl:table-cell' },
{ key: 'score', label: 'Score', align: 'right' }
]
</script>
<template>
<div>
<PageHeader
title="Leaderboard"
subtitle="Top contributors ranked by their velocity score"
icon="fas fa-trophy"
icon-color="text-yellow-500"
centered
/>
<!-- Search and Leaderboard -->
<section class="py-4 sm:py-8 px-4">
<div class="container mx-auto max-w-5xl">
<!-- Search Input -->
<div class="mb-4 sm:mb-6">
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"></i>
<input
v-model="searchQuery"
type="text"
placeholder="Search contributors..."
class="w-full pl-10 pr-10 py-2.5 rounded-lg border border-gray-700 bg-gray-800 text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition text-sm sm:text-base"
/>
<button
v-if="searchQuery"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
@click="searchQuery = ''"
>
<i class="fas fa-times"></i>
</button>
</div>
<p v-if="searchQuery && leaderboard.length !== allContributors.length" class="mt-2 text-sm text-gray-400">
Showing {{ leaderboard.length }} of {{ allContributors.length }} contributors
</p>
</div>
<!-- Mobile Card Layout -->
<div class="md:hidden space-y-3">
<RouterLink
v-for="item in leaderboard"
:key="item.login"
:to="{ name: 'contributor', params: { login: item.login } }"
class="block"
>
<Card hover class="!p-4">
<div class="flex items-center gap-3">
<!-- Rank -->
<RankBadge :rank="item.rank" size="sm" />
<!-- Avatar -->
<Avatar :src="item.avatar_url" :name="item.login" size="md" />
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="font-semibold text-white truncate">
{{ item.name || item.login }}
</div>
<div class="text-xs text-gray-400 truncate">
@{{ item.login }}
</div>
</div>
<!-- Score -->
<div class="text-right">
<div class="text-lg font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
{{ formatNumber(item.score) }}
</div>
<div class="text-xs text-gray-400">pts</div>
</div>
</div>
<!-- Achievements row -->
<div v-if="item.achievements?.length" class="mt-3 pt-3 border-t border-gray-700">
<div class="flex flex-wrap gap-1.5">
<AchievementBadge
v-for="achievement in getHighestTierAchievements(item.achievements).slice(0, 6)"
:key="achievement"
:achievement-id="achievement"
size="sm"
/>
<span
v-if="getHighestTierAchievements(item.achievements).length > 6"
class="inline-flex items-center justify-center px-2 h-7 rounded-lg bg-gray-700 text-gray-300 text-xs font-bold"
>
+{{ getHighestTierAchievements(item.achievements).length - 6 }}
</span>
</div>
</div>
</Card>
</RouterLink>
<!-- Empty State -->
<div v-if="!leaderboard.length" class="text-center py-12">
<i class="fas fa-users text-4xl text-gray-500 mb-4"></i>
<p class="text-gray-400">No contributors found</p>
</div>
</div>
<!-- Desktop Table Layout -->
<div class="hidden md:block">
<DataTable
:columns="tableColumns"
:items="leaderboard"
empty-icon="fas fa-users"
empty-message="No contributors found"
row-class="hover:bg-gray-800/30 transition group"
>
<template #rank="{ item }">
<RankBadge :rank="item.rank" />
</template>
<template #contributor="{ item }">
<ContributorRow :contributor="item" show-github-link />
</template>
<template #achievements="{ item }">
<td class="hidden md:table-cell">
<div class="flex flex-wrap gap-1.5 max-w-[280px]">
<AchievementBadge
v-for="achievement in getHighestTierAchievements(item.achievements)"
:key="achievement"
:achievement-id="achievement"
size="sm"
/>
<span v-if="!(item.achievements || []).length" class="text-gray-400 text-sm">-</span>
</div>
</td>
</template>
<template #team="{ item }">
<td class="hidden xl:table-cell">
<span
v-if="item.team"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-900/30 text-purple-300"
>
{{ item.team }}
</span>
<span v-else class="text-gray-400">-</span>
</td>
</template>
<template #score="{ item }">
<span class="text-lg font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
{{ formatNumber(item.score) }}
</span>
</template>
</DataTable>
</div>
</div>
</section>
</div>
</template>