mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-10-14 23:38:05 +00:00
Initial commit.
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, inject } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import LoadingState from '../components/LoadingState.vue'
|
||||
import ErrorState from '../components/ErrorState.vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import Avatar from '../components/Avatar.vue'
|
||||
import AchievementBadge from '../components/AchievementBadge.vue'
|
||||
import AchievementProgress from '../components/AchievementProgress.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
import GithubLink from '../components/GithubLink.vue'
|
||||
import { formatNumber, formatPercent, formatDuration } from '../composables/formatters'
|
||||
|
||||
const route = useRoute()
|
||||
const globalData = inject('globalData')
|
||||
const contributor = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Contributors' },
|
||||
{ label: contributor.value?.login || route.params.login }
|
||||
])
|
||||
|
||||
async function loadContributor() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const login = route.params.login
|
||||
|
||||
try {
|
||||
const response = await fetch(`./data/contributors/${login}.json`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
const leaderboard = globalData.value?.leaderboard || []
|
||||
const leaderboardEntry = leaderboard.find(e => e.login === login)
|
||||
|
||||
if (leaderboardEntry) {
|
||||
data.score = {
|
||||
total: leaderboardEntry.score,
|
||||
rank: leaderboardEntry.rank,
|
||||
breakdown: data.score?.breakdown
|
||||
}
|
||||
data.achievements = leaderboardEntry.achievements
|
||||
}
|
||||
|
||||
contributor.value = data
|
||||
} else {
|
||||
const leaderboard = globalData.value?.leaderboard || []
|
||||
let found = leaderboard.find(e => e.login === login)
|
||||
|
||||
if (!found) {
|
||||
const repos = globalData.value?.repositories || []
|
||||
for (const repo of repos) {
|
||||
const c = repo.contributors?.find(c => c.login === login)
|
||||
if (c) {
|
||||
found = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
contributor.value = found
|
||||
} else {
|
||||
error.value = 'Contributor not found'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = `Failed to load contributor: ${e.message}`
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadContributor)
|
||||
watch(() => route.params, loadContributor)
|
||||
watch(globalData, loadContributor)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LoadingState v-if="loading" message="Loading contributor..." />
|
||||
<ErrorState v-else-if="error" :message="error" />
|
||||
|
||||
<template v-else-if="contributor">
|
||||
<!-- Profile Header -->
|
||||
<header class="py-12 px-4">
|
||||
<div class="container mx-auto">
|
||||
<PageHeader :breadcrumbs="breadcrumbs" :title="''" />
|
||||
|
||||
<div class="flex flex-col md:flex-row items-center md:items-start space-y-4 md:space-y-0 md:space-x-8">
|
||||
<Avatar
|
||||
:src="contributor.avatar_url"
|
||||
:name="contributor.login"
|
||||
size="2xl"
|
||||
class="shadow-modern"
|
||||
/>
|
||||
|
||||
<div class="text-center md:text-left">
|
||||
<h1 class="text-4xl font-bold gradient-text">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</h1>
|
||||
<p class="text-xl text-gray-500 dark:text-gray-400 mt-1">
|
||||
<GithubLink :url="`https://github.com/${contributor.login}`">
|
||||
@{{ contributor.login }}
|
||||
</GithubLink>
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-center md:justify-start space-x-4 mt-4">
|
||||
<div class="score-card rounded-lg px-4 py-2">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Score:</span>
|
||||
<span class="text-2xl font-bold gradient-text ml-2">
|
||||
{{ formatNumber(contributor.score?.total || contributor.score || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.score?.rank" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Rank #{{ contributor.score.rank }}
|
||||
<span v-if="contributor.score?.percentile_rank">
|
||||
(Top {{ formatPercent(contributor.score.percentile_rank) }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="contributor.achievements?.length" class="mt-6 flex flex-wrap justify-center md:justify-start gap-3">
|
||||
<AchievementBadge
|
||||
v-for="achievement in contributor.achievements"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="lg"
|
||||
show-label
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
:value="contributor.commit_count || 0"
|
||||
label="Commits"
|
||||
icon="fas fa-code-commit"
|
||||
icon-color="text-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="contributor.prs_opened || 0"
|
||||
label="PRs Opened"
|
||||
icon="fas fa-code-pull-request"
|
||||
icon-color="text-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="contributor.prs_merged || 0"
|
||||
label="PRs Merged"
|
||||
icon="fas fa-code-merge"
|
||||
icon-color="text-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="contributor.reviews_given || 0"
|
||||
label="Reviews Given"
|
||||
icon="fas fa-eye"
|
||||
icon-color="text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Detailed Stats -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Code Stats -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<i class="fas fa-code text-green-500 mr-2"></i>Code Contributions
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Lines Added</span>
|
||||
<span class="text-green-500 font-semibold">
|
||||
+{{ formatNumber(contributor.lines_added || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Lines Deleted</span>
|
||||
<span class="text-red-500 font-semibold">
|
||||
-{{ formatNumber(contributor.lines_deleted || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Files Changed</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
{{ formatNumber(contributor.files_changed || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.avg_pr_size" class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Avg PR Size</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
{{ formatNumber(Math.round(contributor.avg_pr_size)) }} lines
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review Stats -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<i class="fas fa-comments text-purple-500 mr-2"></i>Review Activity
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Reviews Given</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
{{ formatNumber(contributor.reviews_given || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Approvals</span>
|
||||
<span class="text-green-500 font-semibold">
|
||||
{{ formatNumber(contributor.approvals_given || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Changes Requested</span>
|
||||
<span class="text-orange-500 font-semibold">
|
||||
{{ formatNumber(contributor.changes_requested || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Review Comments</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
{{ formatNumber(contributor.review_comments || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.avg_review_time_hours" class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Avg Review Time</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
{{ formatDuration(contributor.avg_review_time_hours) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Score Breakdown -->
|
||||
<section v-if="contributor.score?.breakdown" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<i class="fas fa-chart-pie gradient-text mr-2"></i>Score Breakdown
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-green-500">
|
||||
{{ formatNumber(contributor.score.breakdown.commits || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Commits</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-blue-500">
|
||||
{{ formatNumber(contributor.score.breakdown.prs || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">PRs</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-purple-500">
|
||||
{{ formatNumber(contributor.score.breakdown.reviews || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Reviews</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-orange-500">
|
||||
{{ formatNumber(contributor.score.breakdown.line_changes || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Line Changes</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-yellow-500">
|
||||
{{ formatNumber(contributor.score.breakdown.response_bonus || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Response Bonus</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Achievement Progress Section -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Earned Achievements -->
|
||||
<div v-if="contributor.achievements?.length" class="card">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
<i class="fas fa-award gradient-text mr-2"></i>Achievements Earned
|
||||
</h3>
|
||||
<span class="px-2.5 py-1 rounded-full bg-gradient-to-r from-yellow-400 to-amber-500 text-white text-sm font-bold shadow-md">
|
||||
{{ contributor.achievements.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 sm:grid-cols-5 gap-3">
|
||||
<div
|
||||
v-for="achievement in contributor.achievements"
|
||||
:key="achievement"
|
||||
class="flex flex-col items-center p-2 rounded-xl bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<AchievementBadge
|
||||
:achievement-id="achievement"
|
||||
size="md"
|
||||
show-label
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress to Next Achievements -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">
|
||||
<i class="fas fa-chart-line text-primary-500 mr-2"></i>Next Achievements
|
||||
</h3>
|
||||
|
||||
<AchievementProgress
|
||||
:contributor="contributor"
|
||||
:max-display="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Repositories Contributed -->
|
||||
<section v-if="contributor.repositories_contributed?.length" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader
|
||||
:title="`Contributed to ${contributor.repositories_contributed.length} Repositories`"
|
||||
icon="fas fa-folder-tree"
|
||||
icon-color="text-blue-500"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<RouterLink
|
||||
v-for="repo in contributor.repositories_contributed"
|
||||
:key="repo"
|
||||
:to="`/repos/${repo}`"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-primary-100 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
|
||||
>
|
||||
<i class="fas fa-code-branch text-gray-400 mr-2"></i>
|
||||
{{ repo }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
import { inject, computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ContributorCard from '../components/ContributorCard.vue'
|
||||
import RepoCard from '../components/RepoCard.vue'
|
||||
import TeamCard from '../components/TeamCard.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
import VelocityChart from '../components/VelocityChart.vue'
|
||||
import { formatNumber, formatDate } from '../composables/formatters'
|
||||
|
||||
const globalData = inject('globalData')
|
||||
|
||||
const metrics = computed(() => globalData.value || {})
|
||||
const leaderboard = computed(() => metrics.value.leaderboard?.slice(0, 3) || [])
|
||||
const repositories = computed(() => metrics.value.repositories || [])
|
||||
const teams = computed(() => metrics.value.teams || [])
|
||||
const velocityTimeline = computed(() => metrics.value.velocity_timeline)
|
||||
|
||||
const showScoreInChart = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero Section -->
|
||||
<header class="py-16 px-4">
|
||||
<div class="container mx-auto text-center animate-fade-in-up">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-4">
|
||||
<span class="gradient-text">Git Velocity</span>
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
Celebrate your team's achievements and contributions with beautiful insights.
|
||||
</p>
|
||||
<!-- Period and Generation Info -->
|
||||
<div class="flex flex-col items-center space-y-2 mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p v-if="metrics.period?.start || metrics.period?.end">
|
||||
<i class="fas fa-calendar-alt mr-1 text-primary-500"></i>
|
||||
<span class="font-medium">Period:</span>
|
||||
<span v-if="metrics.period.start">{{ formatDate(metrics.period.start) }}</span>
|
||||
<span v-if="metrics.period.start && metrics.period.end"> — </span>
|
||||
<span v-if="metrics.period.end">{{ formatDate(metrics.period.end) }}</span>
|
||||
</p>
|
||||
<p v-if="metrics.generated_at">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
Generated on {{ formatDate(metrics.generated_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Velocity Timeline Chart -->
|
||||
<section v-if="velocityTimeline" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<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-600 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showScoreInChart"
|
||||
class="rounded border-gray-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span>Show Score</span>
|
||||
</label>
|
||||
</div>
|
||||
<VelocityChart :timeline="velocityTimeline" :show-score="showScoreInChart" height="320px" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard
|
||||
:value="metrics.total_contributors || 0"
|
||||
label="Contributors"
|
||||
delay="0s"
|
||||
/>
|
||||
<StatCard
|
||||
:value="metrics.total_commits || 0"
|
||||
label="Commits"
|
||||
delay="0.1s"
|
||||
/>
|
||||
<StatCard
|
||||
:value="metrics.total_prs || 0"
|
||||
label="Pull Requests"
|
||||
delay="0.2s"
|
||||
/>
|
||||
<StatCard
|
||||
:value="metrics.total_reviews || 0"
|
||||
label="Reviews"
|
||||
delay="0.3s"
|
||||
/>
|
||||
<StatCard
|
||||
:value="'+' + formatNumber(metrics.total_lines_added || 0)"
|
||||
label="Lines Added"
|
||||
value-class="text-green-500"
|
||||
delay="0.4s"
|
||||
/>
|
||||
<StatCard
|
||||
:value="'-' + formatNumber(metrics.total_lines_deleted || 0)"
|
||||
label="Lines Deleted"
|
||||
value-class="text-red-500"
|
||||
delay="0.5s"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Top Contributors -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Top Contributors" icon="fas fa-trophy" icon-color="text-yellow-500" />
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<ContributorCard
|
||||
v-for="(entry, index) in leaderboard"
|
||||
:key="entry.login"
|
||||
:contributor="entry"
|
||||
:rank="index + 1"
|
||||
featured
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<RouterLink to="/leaderboard" class="btn-primary">
|
||||
View Full Leaderboard
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Repositories -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Repositories" icon="fas fa-code-branch" icon-color="text-accent-500" />
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<RepoCard v-for="repo in repositories" :key="`${repo.owner}/${repo.name}`" :repo="repo" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Teams -->
|
||||
<section v-if="teams.length" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Teams" icon="fas fa-users" icon-color="text-blue-500" />
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<TeamCard v-for="team in teams" :key="team.name" :team="team" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import { inject, computed } from '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 AchievementBadge from '../components/AchievementBadge.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
const globalData = inject('globalData')
|
||||
const leaderboard = computed(() => globalData.value?.leaderboard || [])
|
||||
|
||||
const tableColumns = [
|
||||
{ key: 'rank', label: 'Rank', align: 'left' },
|
||||
{ key: 'contributor', label: 'Contributor', align: 'left' },
|
||||
{ key: 'achievements', label: 'Achievements', align: 'left' },
|
||||
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden md:table-cell' },
|
||||
{ key: 'category', label: 'Best At', align: 'left', headerClass: 'hidden sm: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>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Leaderboard"
|
||||
subtitle="Top contributors ranked by their velocity score"
|
||||
icon="fas fa-trophy"
|
||||
icon-color="text-yellow-500"
|
||||
centered
|
||||
/>
|
||||
|
||||
<!-- Leaderboard Table -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto max-w-5xl">
|
||||
<DataTable
|
||||
:columns="tableColumns"
|
||||
:items="leaderboard"
|
||||
empty-icon="fas fa-users"
|
||||
empty-message="No contributors found"
|
||||
row-class="hover:bg-gray-50 dark: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 }">
|
||||
<div class="flex flex-wrap gap-1.5 max-w-[180px]">
|
||||
<AchievementBadge
|
||||
v-for="achievement in (item.achievements || []).slice(0, 6)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span v-if="!(item.achievements || []).length" class="text-gray-400 text-sm">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #team="{ item }">
|
||||
<td class="hidden md: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-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300"
|
||||
>
|
||||
{{ item.team }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template #category="{ item }">
|
||||
<td class="hidden sm:table-cell">
|
||||
<span v-if="item.top_category" class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<i :class="categoryIcon(item.top_category)" class="mr-1"></i>
|
||||
{{ item.top_category }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template #score="{ item }">
|
||||
<span class="text-lg font-bold gradient-text">
|
||||
{{ formatNumber(item.score) }}
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import LoadingState from '../components/LoadingState.vue'
|
||||
import ErrorState from '../components/ErrorState.vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import DataTable from '../components/DataTable.vue'
|
||||
import ContributorRow from '../components/ContributorRow.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
import GithubLink from '../components/GithubLink.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
const route = useRoute()
|
||||
const repository = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Repositories' },
|
||||
{ label: repository.value?.name || route.params.name }
|
||||
])
|
||||
|
||||
const tableColumns = [
|
||||
{ key: 'contributor', label: 'Contributor', align: 'left' },
|
||||
{ key: 'commits', label: 'Commits', align: 'center' },
|
||||
{ key: 'prs', label: 'PRs', align: 'center' },
|
||||
{ key: 'reviews', label: 'Reviews', align: 'center' },
|
||||
{ key: 'lines', label: 'Lines +/-', align: 'center' },
|
||||
{ key: 'score', label: 'Score', align: 'right' }
|
||||
]
|
||||
|
||||
async function loadRepository() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch(`./data/repos/${route.params.owner}/${route.params.name}/metrics.json`)
|
||||
if (!response.ok) throw new Error('Repository not found')
|
||||
repository.value = await response.json()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRepository)
|
||||
watch(() => route.params, loadRepository)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LoadingState v-if="loading" message="Loading repository..." />
|
||||
<ErrorState v-else-if="error" :message="error" />
|
||||
|
||||
<template v-else-if="repository">
|
||||
<PageHeader
|
||||
:title="repository.name"
|
||||
icon="fas fa-code-branch"
|
||||
icon-color="text-accent-500"
|
||||
:breadcrumbs="breadcrumbs"
|
||||
>
|
||||
<template #subtitle>
|
||||
<GithubLink :url="`https://github.com/${repository.owner}/${repository.name}`">
|
||||
{{ repository.owner }}/{{ repository.name }}
|
||||
</GithubLink>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Stats -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
:value="repository.total_commits"
|
||||
label="Commits"
|
||||
icon="fas fa-code-commit"
|
||||
icon-color="text-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="repository.total_prs"
|
||||
label="Pull Requests"
|
||||
icon="fas fa-code-pull-request"
|
||||
icon-color="text-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="repository.total_reviews"
|
||||
label="Reviews"
|
||||
icon="fas fa-eye"
|
||||
icon-color="text-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="repository.active_contributors"
|
||||
label="Contributors"
|
||||
icon="fas fa-users"
|
||||
icon-color="text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contributors -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Contributors" icon="fas fa-users" icon-color="text-blue-500" />
|
||||
|
||||
<DataTable
|
||||
:columns="tableColumns"
|
||||
:items="repository.contributors"
|
||||
empty-icon="fas fa-users"
|
||||
empty-message="No contributors found"
|
||||
row-class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition group"
|
||||
>
|
||||
<template #contributor="{ item }">
|
||||
<ContributorRow :contributor="item" />
|
||||
</template>
|
||||
<template #commits="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.commit_count) }}</span>
|
||||
</template>
|
||||
<template #prs="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.prs_opened) }}</span>
|
||||
</template>
|
||||
<template #reviews="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.reviews_given) }}</span>
|
||||
</template>
|
||||
<template #lines="{ item }">
|
||||
<span class="text-green-500">+{{ formatNumber(item.lines_added) }}</span>
|
||||
<span class="text-gray-400 mx-1">/</span>
|
||||
<span class="text-red-500">-{{ formatNumber(item.lines_deleted) }}</span>
|
||||
</template>
|
||||
<template #score="{ item }">
|
||||
<span class="text-lg font-bold gradient-text">
|
||||
{{ formatNumber(item.score?.total || 0) }}
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, inject } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import LoadingState from '../components/LoadingState.vue'
|
||||
import ErrorState from '../components/ErrorState.vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import MemberCard from '../components/MemberCard.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
import { slugify } from '../composables/formatters'
|
||||
|
||||
const route = useRoute()
|
||||
const globalData = inject('globalData')
|
||||
const team = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Teams' },
|
||||
{ label: team.value?.name || route.params.slug }
|
||||
])
|
||||
|
||||
function loadTeam() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const teams = globalData.value?.teams || []
|
||||
const found = teams.find(t => slugify(t.name) === route.params.slug)
|
||||
|
||||
if (found) {
|
||||
team.value = found
|
||||
} else {
|
||||
error.value = 'Team not found'
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadTeam)
|
||||
watch(() => route.params, loadTeam)
|
||||
watch(globalData, loadTeam)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LoadingState v-if="loading" message="Loading team..." />
|
||||
<ErrorState v-else-if="error" :message="error" />
|
||||
|
||||
<template v-else-if="team">
|
||||
<PageHeader
|
||||
:title="team.name"
|
||||
:breadcrumbs="breadcrumbs"
|
||||
:subtitle="`${team.members?.length || 0} team members`"
|
||||
>
|
||||
<template #prefix>
|
||||
<div
|
||||
class="w-4 h-4 rounded-full mr-4"
|
||||
:style="{ backgroundColor: team.color || '#8b5cf6' }"
|
||||
></div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Team Stats -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
:value="team.total_score"
|
||||
label="Total Score"
|
||||
icon="fas fa-star"
|
||||
icon-color="text-yellow-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="team.aggregated_metrics?.commit_count || 0"
|
||||
label="Commits"
|
||||
icon="fas fa-code-commit"
|
||||
icon-color="text-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="team.aggregated_metrics?.prs_merged || 0"
|
||||
label="PRs Merged"
|
||||
icon="fas fa-code-merge"
|
||||
icon-color="text-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="team.aggregated_metrics?.reviews_given || 0"
|
||||
label="Reviews"
|
||||
icon="fas fa-eye"
|
||||
icon-color="text-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Team Members -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Team Members" icon="fas fa-users" icon-color="text-blue-500" />
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<MemberCard
|
||||
v-for="member in team.member_metrics"
|
||||
:key="member.login"
|
||||
:member="member"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user