mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-06 22:49:27 +00:00
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
This commit is contained in:
@@ -234,7 +234,6 @@ const progressItems = computed(() => {
|
||||
|
||||
// Get count of remaining achievements (all unearned across all types)
|
||||
const remainingCount = computed(() => {
|
||||
const earnedSet = new Set(props.contributor.achievements || [])
|
||||
let totalUnearned = 0
|
||||
|
||||
for (const type of achievementTypes) {
|
||||
|
||||
@@ -13,6 +13,6 @@ defineProps({
|
||||
hover ? 'hover:shadow-lg transition-shadow' : ''
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Avatar from './Avatar.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
contributor: {
|
||||
|
||||
@@ -51,8 +51,8 @@ const repositories = computed(() => globalData.value?.Repositories || [])
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
class="md:hidden p-2 rounded-lg hover:bg-gray-700 transition"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
>
|
||||
<i class="fas fa-bars text-gray-200"></i>
|
||||
</button>
|
||||
@@ -63,37 +63,37 @@ const repositories = computed(() => globalData.value?.Repositories || [])
|
||||
<div class="flex flex-col space-y-1">
|
||||
<RouterLink
|
||||
to="/"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="[
|
||||
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
|
||||
route.path === '/'
|
||||
? 'bg-primary-900/20 text-primary-400'
|
||||
: 'text-gray-200 hover:bg-gray-800'
|
||||
]"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
<i class="fas fa-home mr-3 w-5 text-center"></i>Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/leaderboard"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="[
|
||||
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
|
||||
route.path === '/leaderboard'
|
||||
? 'bg-primary-900/20 text-primary-400'
|
||||
: 'text-gray-200 hover:bg-gray-800'
|
||||
]"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
<i class="fas fa-trophy mr-3 w-5 text-center"></i>Leaderboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/how-scoring-works"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="[
|
||||
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
|
||||
route.path === '/how-scoring-works'
|
||||
? 'bg-primary-900/20 text-primary-400'
|
||||
: 'text-gray-200 hover:bg-gray-800'
|
||||
]"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
<i class="fas fa-calculator mr-3 w-5 text-center"></i>How Scoring Works
|
||||
</RouterLink>
|
||||
@@ -101,13 +101,13 @@ const repositories = computed(() => globalData.value?.Repositories || [])
|
||||
v-for="repo in repositories"
|
||||
:key="`${repo.Owner}/${repo.Name}`"
|
||||
:to="`/repos/${repo.Owner}/${repo.Name}`"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="[
|
||||
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
|
||||
route.path.includes(`/repos/${repo.Owner}/${repo.Name}`)
|
||||
? 'bg-primary-900/20 text-primary-400'
|
||||
: 'text-gray-200 hover:bg-gray-800'
|
||||
]"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
<i class="fas fa-code-branch mr-3 w-5 text-center"></i>{{ repo.Name }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RouterLink } from 'vue-router'
|
||||
import Card from './Card.vue'
|
||||
import Avatar from './Avatar.vue'
|
||||
import { formatNumber, slugify } from '../composables/formatters'
|
||||
import { DEFAULT_TEAM_COLOR } from '../composables/constants'
|
||||
|
||||
defineProps({
|
||||
team: {
|
||||
@@ -24,7 +25,7 @@ defineProps({
|
||||
</h3>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: team.color || '#8b5cf6' }"
|
||||
:style="{ backgroundColor: team.color || DEFAULT_TEAM_COLOR }"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Application constants
|
||||
*/
|
||||
|
||||
// Default colors
|
||||
export const DEFAULT_TEAM_COLOR = '#8b5cf6' // Purple - matches accent color palette
|
||||
|
||||
// Data paths
|
||||
export const DATA_BASE_PATH = './data'
|
||||
export const GLOBAL_DATA_PATH = `${DATA_BASE_PATH}/global.json`
|
||||
export const CONTRIBUTORS_PATH = `${DATA_BASE_PATH}/contributors`
|
||||
export const REPOS_PATH = `${DATA_BASE_PATH}/repos`
|
||||
@@ -1,13 +1,21 @@
|
||||
// Number formatting thresholds
|
||||
const ONE_MILLION = 1_000_000
|
||||
const ONE_THOUSAND = 1_000
|
||||
|
||||
// Time conversion constants
|
||||
const MINUTES_PER_HOUR = 60
|
||||
const HOURS_PER_DAY = 24
|
||||
|
||||
/**
|
||||
* Format a number with K/M suffixes for large values
|
||||
*/
|
||||
export function formatNumber(n) {
|
||||
if (n === null || n === undefined) return '0'
|
||||
if (n >= 1000000) {
|
||||
return (n / 1000000).toFixed(1) + 'M'
|
||||
if (n >= ONE_MILLION) {
|
||||
return (n / ONE_MILLION).toFixed(1) + 'M'
|
||||
}
|
||||
if (n >= 1000) {
|
||||
return (n / 1000).toFixed(1) + 'K'
|
||||
if (n >= ONE_THOUSAND) {
|
||||
return (n / ONE_THOUSAND).toFixed(1) + 'K'
|
||||
}
|
||||
return String(n)
|
||||
}
|
||||
@@ -16,14 +24,14 @@ export function formatNumber(n) {
|
||||
* Format hours as a human-readable duration
|
||||
*/
|
||||
export function formatDuration(hours) {
|
||||
if (hours === null || hours === undefined) return '-'
|
||||
if (hours === null || hours === undefined || hours <= 0) return '-'
|
||||
if (hours < 1) {
|
||||
return Math.round(hours * 60) + 'm'
|
||||
return Math.round(hours * MINUTES_PER_HOUR) + 'm'
|
||||
}
|
||||
if (hours < 24) {
|
||||
if (hours < HOURS_PER_DAY) {
|
||||
return hours.toFixed(1) + 'h'
|
||||
}
|
||||
return (hours / 24).toFixed(1) + 'd'
|
||||
return (hours / HOURS_PER_DAY).toFixed(1) + 'd'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,8 +79,22 @@ async function loadContributor() {
|
||||
}
|
||||
|
||||
onMounted(loadContributor)
|
||||
watch(() => route.params, loadContributor)
|
||||
watch(globalData, loadContributor)
|
||||
|
||||
// Watch for route changes (navigation to different contributor)
|
||||
watch(() => route.params.login, (newLogin, oldLogin) => {
|
||||
if (newLogin && newLogin !== oldLogin) {
|
||||
loadContributor()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for globalData changes, but only reload if we don't have contributor data yet
|
||||
// This prevents double-loading when both route and globalData change on initial navigation
|
||||
watch(globalData, (newData, oldData) => {
|
||||
// Only reload if globalData became available and we have an error or no data
|
||||
if (newData && !oldData && (error.value || !contributor.value)) {
|
||||
loadContributor()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -57,8 +57,8 @@ const showScoreInChart = ref(false)
|
||||
<SectionHeader title="Velocity Timeline" icon="fas fa-chart-line" icon-color="text-primary-500" />
|
||||
<label class="flex items-center space-x-2 text-sm text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showScoreInChart"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-600 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span>Show Score</span>
|
||||
|
||||
@@ -64,7 +64,7 @@ import SectionHeader from '../components/SectionHeader.vue'
|
||||
Score Formula
|
||||
</h3>
|
||||
<div class="bg-gray-900 text-gray-100 p-3 sm:p-4 rounded-lg overflow-x-auto mb-4 -mx-2 sm:mx-0">
|
||||
<pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Response
|
||||
<pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Tests + Response
|
||||
|
||||
Where:
|
||||
Commits = sum of (commits x 10 x time_multiplier)
|
||||
@@ -73,6 +73,7 @@ Where:
|
||||
Reviews = reviews_given x 30 pts
|
||||
Comments = review_comments x 5 pts
|
||||
Issues = (opened x 10) + (closed x 20) + (comments x 5) + (refs x 5) pts
|
||||
Tests = commits_with_tests x 15 pts
|
||||
Response = fast review bonus (0-50 pts)
|
||||
|
||||
Time Multipliers:
|
||||
|
||||
@@ -34,16 +34,6 @@ const tableColumns = [
|
||||
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden xl:table-cell' },
|
||||
{ key: 'score', label: 'Score', align: 'right' }
|
||||
]
|
||||
|
||||
const categoryIcon = (category) => {
|
||||
const icons = {
|
||||
'Commits': 'fas fa-code-commit text-green-500',
|
||||
'PRs': 'fas fa-code-pull-request text-blue-500',
|
||||
'Reviews': 'fas fa-eye text-purple-500',
|
||||
'Comments': 'fas fa-comment text-orange-500'
|
||||
}
|
||||
return icons[category] || ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -71,8 +61,8 @@ const categoryIcon = (category) => {
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="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>
|
||||
|
||||
@@ -61,7 +61,13 @@ async function loadRepository() {
|
||||
}
|
||||
|
||||
onMounted(loadRepository)
|
||||
watch(() => route.params, loadRepository)
|
||||
|
||||
// Watch for route changes (navigation to different repository)
|
||||
watch(() => [route.params.owner, route.params.name], ([newOwner, newName], [oldOwner, oldName]) => {
|
||||
if ((newOwner && newName) && (newOwner !== oldOwner || newName !== oldName)) {
|
||||
loadRepository()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -132,8 +138,8 @@ watch(() => route.params, loadRepository)
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="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>
|
||||
|
||||
+16
-3
@@ -8,6 +8,7 @@ import StatCard from '../components/StatCard.vue'
|
||||
import MemberCard from '../components/MemberCard.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
import { slugify } from '../composables/formatters'
|
||||
import { DEFAULT_TEAM_COLOR } from '../composables/constants'
|
||||
|
||||
const route = useRoute()
|
||||
const globalData = inject('globalData')
|
||||
@@ -38,8 +39,20 @@ function loadTeam() {
|
||||
}
|
||||
|
||||
onMounted(loadTeam)
|
||||
watch(() => route.params, loadTeam)
|
||||
watch(globalData, loadTeam)
|
||||
|
||||
// Watch for route changes (navigation to different team)
|
||||
watch(() => route.params.slug, (newSlug, oldSlug) => {
|
||||
if (newSlug && newSlug !== oldSlug) {
|
||||
loadTeam()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for globalData changes, but only reload if we don't have team data yet
|
||||
watch(globalData, (newData, oldData) => {
|
||||
if (newData && !oldData && (error.value || !team.value)) {
|
||||
loadTeam()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -56,7 +69,7 @@ watch(globalData, loadTeam)
|
||||
<template #prefix>
|
||||
<div
|
||||
class="w-4 h-4 rounded-full mr-4"
|
||||
:style="{ backgroundColor: team.color || '#8b5cf6' }"
|
||||
:style="{ backgroundColor: team.color || DEFAULT_TEAM_COLOR }"
|
||||
></div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
Reference in New Issue
Block a user