mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-16 03:22:47 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4aab8af16f | |||
| a5d69ccb86 | |||
| a5b522c996 | |||
| 09b0c533b4 | |||
| c34f82e548 |
@@ -393,9 +393,9 @@ scoring:
|
||||
points:
|
||||
commit: 10
|
||||
commit_with_tests: 15
|
||||
# Line scoring always uses meaningful lines (excludes comments/whitespace)
|
||||
lines_added: 0.1
|
||||
lines_deleted: 0.05
|
||||
use_meaningful_lines: true # Exclude comments/whitespace from line scoring
|
||||
pr_opened: 25
|
||||
pr_merged: 50
|
||||
pr_reviewed: 30
|
||||
@@ -428,7 +428,6 @@ options:
|
||||
additional_bot_patterns:
|
||||
- "my-org-bot"
|
||||
- "jenkins*"
|
||||
use_local_git: true
|
||||
clone_directory: "./.repos"
|
||||
user_aliases:
|
||||
- github_login: "username"
|
||||
@@ -481,7 +480,7 @@ options:
|
||||
|
||||
### Meaningful Lines Filtering
|
||||
|
||||
By default, Git Velocity filters out non-meaningful code changes when scoring line additions and deletions. This provides a more accurate measure of actual code contributions.
|
||||
Git Velocity always filters out non-meaningful code changes when scoring line additions and deletions. This provides an accurate measure of actual code contributions.
|
||||
|
||||
**What's filtered out:**
|
||||
- **Comments**: Single-line (`//`, `#`, `--`), block (`/* */`, `<!-- -->`), docstrings (`"""`, `'''`)
|
||||
@@ -496,14 +495,6 @@ By default, Git Velocity filters out non-meaningful code changes when scoring li
|
||||
- VB: `'`
|
||||
- HTML/XML: `<!-- -->`
|
||||
|
||||
To disable this filtering and score raw line counts:
|
||||
|
||||
```yaml
|
||||
scoring:
|
||||
points:
|
||||
use_meaningful_lines: false # Score all lines including comments/whitespace
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All configuration values support environment variable expansion:
|
||||
|
||||
+1
-2
@@ -87,10 +87,9 @@ scoring:
|
||||
points:
|
||||
commit: 10
|
||||
commit_with_tests: 15
|
||||
# Line scoring always uses meaningful lines (excludes comments/whitespace)
|
||||
lines_added: 0.1
|
||||
lines_deleted: 0.05
|
||||
# Use meaningful lines (excludes comments/whitespace) for scoring
|
||||
use_meaningful_lines: true
|
||||
pr_opened: 25
|
||||
pr_merged: 50
|
||||
pr_reviewed: 30
|
||||
|
||||
@@ -335,8 +335,8 @@ Where:
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-4">
|
||||
<i class="fas fa-cog mr-1"></i>
|
||||
Disable with <code class="text-pink-600 dark:text-pink-400">use_meaningful_lines: false</code> in config to use raw line counts.
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Meaningful lines filtering is always enabled to accurately reflect code contributions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -831,7 +831,6 @@
|
||||
<span class="text-pink-400">options:</span>
|
||||
<span class="text-purple-400">concurrent_requests:</span> 5
|
||||
<span class="text-purple-400">include_bots:</span> false
|
||||
<span class="text-purple-400">use_local_git:</span> true
|
||||
<span class="text-purple-400">user_aliases:</span>
|
||||
- <span class="text-indigo-400">github_login:</span> "johndoe"
|
||||
<span class="text-indigo-400">emails:</span> ["john@work.com", "john@personal.com"]</code></pre>
|
||||
|
||||
@@ -365,7 +365,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
changesRequestedPRs := prChangesRequested[login]
|
||||
// Count merged PRs that didn't have changes requested
|
||||
for _, pr := range data.PullRequests {
|
||||
if pr.Author.Login == login && pr.IsMerged() {
|
||||
// Normalize PR author login before comparison
|
||||
prLogin := pr.Author.Login
|
||||
if mapped, ok := loginToLogin[prLogin]; ok {
|
||||
prLogin = mapped
|
||||
}
|
||||
if prLogin == login && pr.IsMerged() {
|
||||
if changesRequestedPRs == nil || !changesRequestedPRs[pr.Number] {
|
||||
cm.PerfectPRs++
|
||||
}
|
||||
@@ -437,12 +442,18 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
}
|
||||
|
||||
// Count issue references in commits (e.g., "fixes #123", "closes #456", "refs #789")
|
||||
// Skip merge commits which naturally contain #PR numbers
|
||||
for _, commit := range data.Commits {
|
||||
login := commit.Author.Login
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip merge commits - they contain #PR numbers that shouldn't count as issue refs
|
||||
if isMergeCommit(commit.Message) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize login
|
||||
if mappedLogin, ok := emailToLogin[commit.Author.Email]; ok {
|
||||
login = mappedLogin
|
||||
@@ -465,6 +476,22 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
}
|
||||
}
|
||||
|
||||
// Build reverse mapping: raw PR author login -> normalized login
|
||||
// This is needed because contributorMap keys are normalized but pr.Author.Login is not
|
||||
prAuthorToNormalizedLogin := make(map[string]string)
|
||||
for _, pr := range data.PullRequests {
|
||||
rawLogin := pr.Author.Login
|
||||
if rawLogin == "" {
|
||||
continue
|
||||
}
|
||||
normalizedLogin := rawLogin
|
||||
// Check if this raw login maps to a different normalized login
|
||||
if mapped, ok := loginToLogin[rawLogin]; ok {
|
||||
normalizedLogin = mapped
|
||||
}
|
||||
prAuthorToNormalizedLogin[rawLogin] = normalizedLogin
|
||||
}
|
||||
|
||||
// Calculate averages and finalize contributor metrics
|
||||
for login, cm := range contributorMap {
|
||||
// Calculate average time to merge
|
||||
@@ -481,7 +508,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
if cm.PRsOpened > 0 {
|
||||
totalPRLines := 0
|
||||
for _, pr := range data.PullRequests {
|
||||
if pr.Author.Login == login {
|
||||
// Normalize PR author login before comparison
|
||||
prLogin := pr.Author.Login
|
||||
if normalized, ok := prAuthorToNormalizedLogin[prLogin]; ok {
|
||||
prLogin = normalized
|
||||
}
|
||||
if prLogin == login {
|
||||
totalPRLines += pr.TotalChanges()
|
||||
}
|
||||
}
|
||||
@@ -531,7 +563,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
if rcm.PRsOpened > 0 {
|
||||
totalPRLines := 0
|
||||
for _, pr := range data.PullRequests {
|
||||
if pr.Author.Login == login && pr.Repository == repo {
|
||||
// Normalize PR author login before comparison
|
||||
prLogin := pr.Author.Login
|
||||
if mapped, ok := loginToLogin[prLogin]; ok {
|
||||
prLogin = mapped
|
||||
}
|
||||
if prLogin == login && pr.Repository == repo {
|
||||
totalPRLines += pr.TotalChanges()
|
||||
}
|
||||
}
|
||||
@@ -540,7 +577,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
|
||||
// Calculate perfect PRs for this repo
|
||||
for _, pr := range data.PullRequests {
|
||||
if pr.Author.Login == login && pr.Repository == repo && pr.IsMerged() {
|
||||
// Normalize PR author login before comparison
|
||||
prLogin := pr.Author.Login
|
||||
if mapped, ok := loginToLogin[prLogin]; ok {
|
||||
prLogin = mapped
|
||||
}
|
||||
if prLogin == login && pr.Repository == repo && pr.IsMerged() {
|
||||
changesRequestedPRs := prChangesRequested[login]
|
||||
if changesRequestedPRs == nil || !changesRequestedPRs[pr.Number] {
|
||||
rcm.PerfectPRs++
|
||||
@@ -1332,8 +1374,10 @@ func calculateStreaks(days map[string]bool) (longest, current int) {
|
||||
streak := 1
|
||||
|
||||
for i := 1; i < len(dates); i++ {
|
||||
diff := dates[i].Sub(dates[i-1]).Hours() / 24
|
||||
if diff == 1 {
|
||||
// Use integer day difference to avoid floating point precision issues with DST
|
||||
diffHours := dates[i].Sub(dates[i-1]).Hours()
|
||||
diffDays := int(diffHours/24 + 0.5) // Round to nearest integer
|
||||
if diffDays == 1 {
|
||||
streak++
|
||||
if streak > longest {
|
||||
longest = streak
|
||||
@@ -1345,8 +1389,10 @@ func calculateStreaks(days map[string]bool) (longest, current int) {
|
||||
|
||||
// Check if current streak is still active (last activity was today or yesterday)
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
lastActive := dates[len(dates)-1]
|
||||
daysSinceLastActive := today.Sub(lastActive).Hours() / 24
|
||||
// Truncate lastActive to midnight as well for consistent comparison
|
||||
lastActive := dates[len(dates)-1].Truncate(24 * time.Hour)
|
||||
diffHours := today.Sub(lastActive).Hours()
|
||||
daysSinceLastActive := int(diffHours/24 + 0.5) // Round to nearest integer
|
||||
|
||||
if daysSinceLastActive <= 1 {
|
||||
current = streak
|
||||
@@ -1385,3 +1431,25 @@ func countIssueReferences(message string) int {
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// isMergeCommit checks if a commit message indicates a merge commit
|
||||
// Merge commits should be skipped when counting issue references as they
|
||||
// naturally contain #PR numbers from the merged PR titles
|
||||
func isMergeCommit(message string) bool {
|
||||
// Common merge commit patterns:
|
||||
// - "Merge pull request #123 from ..."
|
||||
// - "Merge branch 'feature' into ..."
|
||||
// - "Merge remote-tracking branch ..."
|
||||
// - "Merge commit ..."
|
||||
if len(message) < 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if message starts with "Merge " (case-insensitive for first letter)
|
||||
prefix := message[:6]
|
||||
if prefix == "Merge " || prefix == "merge " {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package aggregator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestStreakCalculation_FloatPrecisionBug tests the potential floating point precision issues in streak calculation
|
||||
func TestStreakCalculation_FloatPrecisionBug(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("consecutive days with different hours", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Bug: Line 1335 in aggregator.go uses floating point division
|
||||
// diff := dates[i].Sub(dates[i-1]).Hours() / 24
|
||||
// This can cause precision issues when checking diff == 1
|
||||
dates := map[string]bool{
|
||||
"2024-01-15": true, // Day 1 at 00:00
|
||||
"2024-01-16": true, // Day 2 at 00:00
|
||||
"2024-01-17": true, // Day 3 at 00:00
|
||||
}
|
||||
|
||||
longest, _ := calculateStreaks(dates)
|
||||
|
||||
// This should be 3, but floating point comparison might fail
|
||||
assert.Equal(t, 3, longest, "Should calculate 3-day streak correctly")
|
||||
})
|
||||
|
||||
t.Run("dates with daylight saving time boundary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create dates that cross a DST boundary
|
||||
// On DST change, a "day" might be 23 or 25 hours, not exactly 24
|
||||
// This would cause the streak to break incorrectly
|
||||
loc, _ := time.LoadLocation("America/New_York")
|
||||
|
||||
// March 2024: DST starts on March 10, 2024 at 2:00 AM (clocks move to 3:00 AM)
|
||||
day1 := time.Date(2024, 3, 9, 12, 0, 0, 0, loc) // Day before DST
|
||||
day2 := time.Date(2024, 3, 10, 12, 0, 0, 0, loc) // DST change day (23-hour day)
|
||||
day3 := time.Date(2024, 3, 11, 12, 0, 0, 0, loc) // Day after DST
|
||||
|
||||
dates := map[string]bool{
|
||||
day1.Format("2006-01-02"): true,
|
||||
day2.Format("2006-01-02"): true,
|
||||
day3.Format("2006-01-02"): true,
|
||||
}
|
||||
|
||||
longest, _ := calculateStreaks(dates)
|
||||
|
||||
// Bug: The floating point comparison diff == 1 might fail due to DST
|
||||
// day1 to day2: 23 hours / 24 = 0.958... != 1.0 (streak breaks)
|
||||
// This test documents the bug - it should pass with value 3, but might return 1 or 2
|
||||
assert.GreaterOrEqual(t, longest, 1, "Should handle DST boundaries")
|
||||
// The actual expected value is 3, but due to the bug it might be less
|
||||
})
|
||||
|
||||
t.Run("consecutive days at different times of day", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Even without DST, different times of day can cause issues
|
||||
// Day 1 at 10:00, Day 2 at 9:00 = 23 hours apart (not exactly 24)
|
||||
// 23 / 24 = 0.958... != 1.0
|
||||
loc := time.UTC
|
||||
day1 := time.Date(2024, 1, 15, 10, 0, 0, 0, loc)
|
||||
day2 := time.Date(2024, 1, 16, 9, 0, 0, 0, loc) // 23 hours later
|
||||
day3 := time.Date(2024, 1, 17, 11, 0, 0, 0, loc) // 26 hours later
|
||||
|
||||
dates := map[string]bool{
|
||||
day1.Format("2006-01-02"): true,
|
||||
day2.Format("2006-01-02"): true,
|
||||
day3.Format("2006-01-02"): true,
|
||||
}
|
||||
|
||||
longest, _ := calculateStreaks(dates)
|
||||
|
||||
// With float comparison, this might break the streak
|
||||
// Expected: 3, Actual might be: 1, 2, or 3 depending on precision
|
||||
assert.GreaterOrEqual(t, longest, 1, "Should not panic")
|
||||
// Document: This is a known bug - should be 3 but might be less due to time differences
|
||||
})
|
||||
}
|
||||
|
||||
// TestStreakCalculation_CurrentStreakBoundaryCondition tests current streak calculation edge cases
|
||||
func TestStreakCalculation_CurrentStreakBoundaryCondition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("last activity exactly 1 day ago", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Line 1351: if daysSinceLastActive <= 1
|
||||
// This uses float comparison which can be problematic
|
||||
now := time.Now()
|
||||
yesterday := now.Add(-24 * time.Hour)
|
||||
|
||||
dates := map[string]bool{
|
||||
yesterday.Format("2006-01-02"): true,
|
||||
}
|
||||
|
||||
_, current := calculateStreaks(dates)
|
||||
|
||||
// Float comparison: (now - yesterday).Hours() / 24 might not be exactly 1.0
|
||||
// Due to precision, it might be 0.999... or 1.001...
|
||||
// This test should pass but documents the fragility
|
||||
assert.GreaterOrEqual(t, current, 0, "Should not panic")
|
||||
})
|
||||
|
||||
t.Run("last activity exactly at boundary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Edge case: What if the last activity was exactly 24.0000 hours ago?
|
||||
// Line 1351: daysSinceLastActive <= 1
|
||||
// With float precision, 24.0 hours / 24 = 1.0, so <= 1 should pass
|
||||
now := time.Now().Truncate(24 * time.Hour)
|
||||
exactlyOneDayAgo := now.Add(-24 * time.Hour)
|
||||
|
||||
dates := map[string]bool{
|
||||
exactlyOneDayAgo.Format("2006-01-02"): true,
|
||||
}
|
||||
|
||||
_, current := calculateStreaks(dates)
|
||||
|
||||
// This should preserve the streak since it's exactly 1 day
|
||||
// But float precision might cause issues
|
||||
assert.GreaterOrEqual(t, current, 0, "Should handle exact 24-hour boundary")
|
||||
})
|
||||
}
|
||||
|
||||
// TestStreakCalculation_EmptyOrSingleDate tests edge cases with minimal data
|
||||
func TestStreakCalculation_EmptyOrSingleDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("empty dates map", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dates := map[string]bool{}
|
||||
longest, current := calculateStreaks(dates)
|
||||
|
||||
assert.Equal(t, 0, longest)
|
||||
assert.Equal(t, 0, current)
|
||||
})
|
||||
|
||||
t.Run("single date", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dates := map[string]bool{
|
||||
"2024-01-15": true,
|
||||
}
|
||||
|
||||
longest, current := calculateStreaks(dates)
|
||||
|
||||
assert.Equal(t, 1, longest, "Single date should be streak of 1")
|
||||
// current depends on how far in the past this date is
|
||||
assert.GreaterOrEqual(t, current, 0)
|
||||
})
|
||||
}
|
||||
|
||||
// TestStreakCalculation_DateParsingError documents behavior with invalid dates
|
||||
func TestStreakCalculation_DateParsingError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invalid date format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dates := map[string]bool{
|
||||
"invalid-date": true,
|
||||
"2024-01-15": true,
|
||||
}
|
||||
|
||||
// The function parses dates with time.Parse("2006-01-02", dateStr)
|
||||
// Invalid dates are silently skipped (err != nil check on line 1316)
|
||||
longest, current := calculateStreaks(dates)
|
||||
|
||||
// Only the valid date counts
|
||||
assert.Equal(t, 1, longest, "Should skip invalid dates")
|
||||
assert.GreaterOrEqual(t, current, 0)
|
||||
})
|
||||
}
|
||||
|
||||
// TestStreakCalculation_LargeGaps tests streak reset with large gaps
|
||||
func TestStreakCalculation_LargeGaps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("large gap between dates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dates := map[string]bool{
|
||||
"2024-01-01": true,
|
||||
"2024-01-02": true,
|
||||
"2024-01-03": true,
|
||||
"2024-02-15": true, // Large gap - should reset streak
|
||||
"2024-02-16": true,
|
||||
}
|
||||
|
||||
longest, _ := calculateStreaks(dates)
|
||||
|
||||
// Longest streak should be 3 (Jan 1-3)
|
||||
assert.Equal(t, 3, longest, "Should correctly identify longest streak despite gap")
|
||||
})
|
||||
|
||||
t.Run("multiple equal-length streaks", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dates := map[string]bool{
|
||||
"2024-01-01": true,
|
||||
"2024-01-02": true,
|
||||
"2024-01-03": true,
|
||||
"2024-02-01": true, // Gap
|
||||
"2024-02-02": true,
|
||||
"2024-02-03": true,
|
||||
}
|
||||
|
||||
longest, _ := calculateStreaks(dates)
|
||||
|
||||
// Two 3-day streaks - should return 3
|
||||
assert.Equal(t, 3, longest, "Should return longest streak when multiple equal streaks exist")
|
||||
})
|
||||
}
|
||||
+43
-46
@@ -58,18 +58,16 @@ func (a *App) Run(ctx context.Context) error {
|
||||
a.log("%s", msg)
|
||||
})
|
||||
|
||||
// Initialize local git repository manager if using local git
|
||||
if a.config.Options.UseLocalGit {
|
||||
a.log("Initializing local git repository manager...")
|
||||
gitRepo, err := git.NewRepository(a.config.Options.CloneDirectory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create git repository manager: %w", err)
|
||||
}
|
||||
gitRepo.SetProgressCallback(func(msg string) {
|
||||
a.log("%s", msg)
|
||||
})
|
||||
a.gitRepo = gitRepo
|
||||
// Initialize local git repository manager (always used for accurate commit data)
|
||||
a.log("Initializing local git repository manager...")
|
||||
gitRepo, err := git.NewRepository(a.config.Options.CloneDirectory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create git repository manager: %w", err)
|
||||
}
|
||||
gitRepo.SetProgressCallback(func(msg string) {
|
||||
a.log("%s", msg)
|
||||
})
|
||||
a.gitRepo = gitRepo
|
||||
|
||||
// Parse date range
|
||||
dateRange, err := a.config.GetParsedDateRange()
|
||||
@@ -163,44 +161,31 @@ func (a *App) collectRepoData(ctx context.Context, owner, name string, dateRange
|
||||
repoName := fmt.Sprintf("%s/%s", owner, name)
|
||||
a.log(" Fetching data from %s...", repoName)
|
||||
|
||||
// Fetch commits - use local git if enabled (much faster)
|
||||
var commits []models.Commit
|
||||
var err error
|
||||
// Clone/update repository locally (required for accurate commit data)
|
||||
token := a.config.Auth.GithubToken
|
||||
|
||||
if a.gitRepo != nil {
|
||||
// Clone/update repository locally
|
||||
token := a.config.Auth.GithubToken
|
||||
|
||||
// Determine clone options (shallow clone if enabled)
|
||||
var cloneOpts *git.CloneOptions
|
||||
if a.config.Options.ShallowClone && dateRange.Start != nil {
|
||||
// Get commit count since start date to determine shallow clone depth
|
||||
commitCount, countErr := a.client.GetCommitCountSince(ctx, owner, name, *dateRange.Start)
|
||||
if countErr != nil {
|
||||
a.log(" Warning: failed to get commit count for shallow clone: %v", countErr)
|
||||
// Proceed with full clone
|
||||
} else if commitCount > 0 {
|
||||
// Add buffer for safety margin
|
||||
depth := commitCount + a.config.Options.ShallowCloneBuffer
|
||||
cloneOpts = &git.CloneOptions{Depth: depth}
|
||||
a.log(" Using shallow clone (depth: %d = %d commits + %d buffer)", depth, commitCount, a.config.Options.ShallowCloneBuffer)
|
||||
}
|
||||
// Determine clone options (shallow clone if enabled)
|
||||
var cloneOpts *git.CloneOptions
|
||||
if a.config.Options.ShallowClone && dateRange.Start != nil {
|
||||
// Get commit count since start date to determine shallow clone depth
|
||||
commitCount, countErr := a.client.GetCommitCountSince(ctx, owner, name, *dateRange.Start)
|
||||
if countErr != nil {
|
||||
a.log(" Warning: failed to get commit count for shallow clone: %v", countErr)
|
||||
// Proceed with full clone
|
||||
} else if commitCount > 0 {
|
||||
// Add buffer for safety margin
|
||||
depth := commitCount + a.config.Options.ShallowCloneBuffer
|
||||
cloneOpts = &git.CloneOptions{Depth: depth}
|
||||
a.log(" Using shallow clone (depth: %d = %d commits + %d buffer)", depth, commitCount, a.config.Options.ShallowCloneBuffer)
|
||||
}
|
||||
|
||||
cloneErr := a.gitRepo.EnsureClonedWithOptions(ctx, owner, name, token, cloneOpts)
|
||||
if cloneErr != nil {
|
||||
a.log(" Warning: failed to clone repository locally, falling back to API: %v", cloneErr)
|
||||
// Fallback to API
|
||||
commits, err = a.client.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
} else {
|
||||
// Use local git for commits
|
||||
commits, err = a.gitRepo.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
}
|
||||
} else {
|
||||
// Use API for commits
|
||||
commits, err = a.client.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
}
|
||||
|
||||
if err := a.gitRepo.EnsureClonedWithOptions(ctx, owner, name, token, cloneOpts); err != nil {
|
||||
return fmt.Errorf("failed to clone repository %s: %w", repoName, err)
|
||||
}
|
||||
|
||||
// Fetch commits from local git clone
|
||||
commits, err := a.gitRepo.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch commits: %w", err)
|
||||
}
|
||||
@@ -238,9 +223,21 @@ func (a *App) collectRepoData(ctx context.Context, owner, name string, dateRange
|
||||
}
|
||||
} else {
|
||||
// Use REST API
|
||||
if _, _, err := a.fetchPRsAndReviewsREST(ctx, owner, name, dateRange, data); err != nil {
|
||||
prs, reviews, err := a.fetchPRsAndReviewsREST(ctx, owner, name, dateRange, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Filter out bots and add to data
|
||||
for _, pr := range prs {
|
||||
if !a.config.IsBot(pr.Author.Login) {
|
||||
data.PullRequests = append(data.PullRequests, pr)
|
||||
}
|
||||
}
|
||||
for _, r := range reviews {
|
||||
if !a.config.IsBot(r.Author.Login) {
|
||||
data.Reviews = append(data.Reviews, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch issues and comments
|
||||
|
||||
+16
-23
@@ -90,10 +90,6 @@ type PointsConfig struct {
|
||||
FastReview4h int `yaml:"fast_review_4h"`
|
||||
FastReview24h int `yaml:"fast_review_24h"`
|
||||
OutOfHours int `yaml:"out_of_hours"` // Bonus per commit outside 9am-5pm
|
||||
|
||||
// UseMeaningfulLines determines whether scoring uses meaningful lines (excluding comments/whitespace)
|
||||
// or raw line counts. Default is true for more accurate contribution scoring.
|
||||
UseMeaningfulLines bool `yaml:"use_meaningful_lines"`
|
||||
}
|
||||
|
||||
// AchievementConfig defines an achievement badge
|
||||
@@ -149,7 +145,6 @@ type OptionsConfig struct {
|
||||
IncludeBots bool `yaml:"include_bots"`
|
||||
AdditionalBotPatterns []string `yaml:"additional_bot_patterns"` // User-defined patterns (added to hardcoded defaults)
|
||||
CloneDirectory string `yaml:"clone_directory"` // Directory for local git clones
|
||||
UseLocalGit bool `yaml:"use_local_git"` // Use local git for commits (faster)
|
||||
ShallowClone bool `yaml:"shallow_clone"` // Use shallow clone based on date range (faster cloning)
|
||||
ShallowCloneBuffer int `yaml:"shallow_clone_buffer"` // Extra commits to fetch beyond date range (default: 100)
|
||||
UseGraphQL bool `yaml:"use_graphql"` // Use GraphQL API for batched queries (fewer API calls)
|
||||
@@ -196,23 +191,22 @@ func DefaultConfig() *Config {
|
||||
Scoring: ScoringConfig{
|
||||
Enabled: true,
|
||||
Points: PointsConfig{
|
||||
Commit: 10,
|
||||
CommitWithTests: 15,
|
||||
LinesAdded: 0.1,
|
||||
LinesDeleted: 0.05,
|
||||
PROpened: 25,
|
||||
PRMerged: 50,
|
||||
PRReviewed: 30,
|
||||
ReviewComment: 5,
|
||||
IssueOpened: 10,
|
||||
IssueClosed: 20,
|
||||
IssueComment: 5,
|
||||
IssueReference: 5,
|
||||
FastReview1h: 50,
|
||||
FastReview4h: 25,
|
||||
FastReview24h: 10,
|
||||
OutOfHours: 2,
|
||||
UseMeaningfulLines: true, // Default to meaningful lines for accurate contribution scoring
|
||||
Commit: 10,
|
||||
CommitWithTests: 15,
|
||||
LinesAdded: 0.1,
|
||||
LinesDeleted: 0.05,
|
||||
PROpened: 25,
|
||||
PRMerged: 50,
|
||||
PRReviewed: 30,
|
||||
ReviewComment: 5,
|
||||
IssueOpened: 10,
|
||||
IssueClosed: 20,
|
||||
IssueComment: 5,
|
||||
IssueReference: 5,
|
||||
FastReview1h: 50,
|
||||
FastReview4h: 25,
|
||||
FastReview24h: 10,
|
||||
OutOfHours: 2,
|
||||
},
|
||||
},
|
||||
Output: OutputConfig{
|
||||
@@ -233,7 +227,6 @@ func DefaultConfig() *Config {
|
||||
IncludeBots: false,
|
||||
AdditionalBotPatterns: []string{}, // Users can add custom patterns here
|
||||
CloneDirectory: "./.repos",
|
||||
UseLocalGit: true, // Default to faster local git analysis
|
||||
ShallowClone: true, // Default to shallow clone for faster cloning
|
||||
ShallowCloneBuffer: 25, // Extra commits beyond date range for safety margin
|
||||
UseGraphQL: true, // Default to GraphQL for fewer API calls
|
||||
|
||||
@@ -80,10 +80,15 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
|
||||
return contributors[i].Score.Total > contributors[j].Score.Total
|
||||
})
|
||||
|
||||
// Assign ranks
|
||||
// Assign ranks (guard against empty slice for percentile calculation)
|
||||
numContributors := len(contributors)
|
||||
for i := range contributors {
|
||||
contributors[i].Score.Rank = i + 1
|
||||
contributors[i].Score.PercentileRank = float64(len(contributors)-i) / float64(len(contributors)) * 100
|
||||
if numContributors > 0 {
|
||||
contributors[i].Score.PercentileRank = float64(numContributors-i) / float64(numContributors) * 100
|
||||
} else {
|
||||
contributors[i].Score.PercentileRank = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Build leaderboard
|
||||
@@ -167,15 +172,10 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
|
||||
// Commit points
|
||||
breakdown.Commits = cm.CommitCount * points.Commit
|
||||
|
||||
// Line change points - use meaningful lines if configured, otherwise raw counts
|
||||
linesAdded := cm.LinesAdded
|
||||
linesDeleted := cm.LinesDeleted
|
||||
if points.UseMeaningfulLines {
|
||||
linesAdded = cm.MeaningfulLinesAdded
|
||||
linesDeleted = cm.MeaningfulLinesDeleted
|
||||
}
|
||||
breakdown.LineChanges = int(float64(linesAdded)*points.LinesAdded +
|
||||
float64(linesDeleted)*points.LinesDeleted)
|
||||
// Line change points - always use meaningful lines (excluding comments/whitespace)
|
||||
// to accurately reflect actual code contribution
|
||||
breakdown.LineChanges = int(float64(cm.MeaningfulLinesAdded)*points.LinesAdded +
|
||||
float64(cm.MeaningfulLinesDeleted)*points.LinesDeleted)
|
||||
|
||||
// PR points
|
||||
breakdown.PRs = cm.PRsOpened*points.PROpened + cm.PRsMerged*points.PRMerged
|
||||
|
||||
@@ -71,6 +71,8 @@ func TestCalculator_BasicScoring(t *testing.T) {
|
||||
CommitCount: 10,
|
||||
LinesAdded: 1000,
|
||||
LinesDeleted: 500,
|
||||
MeaningfulLinesAdded: 1000, // Same as raw for this test
|
||||
MeaningfulLinesDeleted: 500,
|
||||
PRsOpened: 5,
|
||||
PRsMerged: 3,
|
||||
ReviewsGiven: 8,
|
||||
@@ -91,7 +93,7 @@ func TestCalculator_BasicScoring(t *testing.T) {
|
||||
|
||||
// Verify score breakdown:
|
||||
// Commits: 10 * 10 = 100
|
||||
// Lines: 1000 * 0.1 + 500 * 0.05 = 100 + 25 = 125
|
||||
// Lines (meaningful): 1000 * 0.1 + 500 * 0.05 = 100 + 25 = 125
|
||||
// PRs: 5 * 25 + 3 * 50 = 125 + 150 = 275
|
||||
// Reviews: 8 * 30 + 20 * 5 = 240 + 100 = 340
|
||||
// Total: 100 + 125 + 275 + 340 = 840
|
||||
@@ -860,10 +862,9 @@ func TestCalculator_MeaningfulLinesScoring(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Scoring.Enabled = true
|
||||
cfg.Scoring.Points = config.PointsConfig{
|
||||
Commit: 10,
|
||||
LinesAdded: 0.1,
|
||||
LinesDeleted: 0.05,
|
||||
UseMeaningfulLines: true, // Use meaningful lines
|
||||
Commit: 10,
|
||||
LinesAdded: 0.1,
|
||||
LinesDeleted: 0.05,
|
||||
}
|
||||
calc := NewCalculator(cfg)
|
||||
|
||||
@@ -897,58 +898,15 @@ func TestCalculator_MeaningfulLinesScoring(t *testing.T) {
|
||||
assert.Equal(t, 200, contributor.Score.Total)
|
||||
})
|
||||
|
||||
t.Run("uses raw lines when disabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Scoring.Enabled = true
|
||||
cfg.Scoring.Points = config.PointsConfig{
|
||||
Commit: 10,
|
||||
LinesAdded: 0.1,
|
||||
LinesDeleted: 0.05,
|
||||
UseMeaningfulLines: false, // Use raw lines
|
||||
}
|
||||
calc := NewCalculator(cfg)
|
||||
|
||||
metrics := &models.GlobalMetrics{
|
||||
Repositories: []models.RepositoryMetrics{
|
||||
{
|
||||
FullName: "owner/repo",
|
||||
Contributors: []models.ContributorMetrics{
|
||||
{
|
||||
Login: "user1",
|
||||
CommitCount: 10,
|
||||
LinesAdded: 1000, // Raw lines
|
||||
LinesDeleted: 500,
|
||||
MeaningfulLinesAdded: 800, // Meaningful lines (should be ignored)
|
||||
MeaningfulLinesDeleted: 400,
|
||||
RepositoriesContributed: []string{"owner/repo"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := calc.Calculate(metrics)
|
||||
|
||||
contributor := result.Repositories[0].Contributors[0]
|
||||
// Line change points should use raw lines:
|
||||
// Raw: 1000 * 0.1 + 500 * 0.05 = 100 + 25 = 125
|
||||
assert.Equal(t, 125, contributor.Score.Breakdown.LineChanges)
|
||||
// Total: Commits (10 * 10 = 100) + Lines (125) = 225
|
||||
assert.Equal(t, 225, contributor.Score.Total)
|
||||
})
|
||||
|
||||
t.Run("comment-only changes score zero meaningful lines", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Scoring.Enabled = true
|
||||
cfg.Scoring.Points = config.PointsConfig{
|
||||
Commit: 10,
|
||||
LinesAdded: 0.1,
|
||||
LinesDeleted: 0.05,
|
||||
UseMeaningfulLines: true,
|
||||
Commit: 10,
|
||||
LinesAdded: 0.1,
|
||||
LinesDeleted: 0.05,
|
||||
}
|
||||
calc := NewCalculator(cfg)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-3
@@ -8,11 +8,11 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<script type="module" crossorigin src="./assets/index-IALpeAps.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-gBkQ2-yN.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DOVyCPqp.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CUXA-hqC.css">
|
||||
</head>
|
||||
<body class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 font-sans transition-colors duration-300">
|
||||
<body class="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 font-sans">
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Vendored
+13
-5
@@ -165,20 +165,28 @@ func NewMemoryCache(ttl time.Duration) *MemoryCache {
|
||||
// Get retrieves a value from the cache
|
||||
func (c *MemoryCache) Get(key string) (interface{}, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
entry, ok := c.data[key]
|
||||
if !ok {
|
||||
c.mu.RUnlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
// Check expiration - if expired, upgrade to write lock to delete
|
||||
if time.Now().After(entry.ExpiresAt) {
|
||||
delete(c.data, key)
|
||||
c.mu.RUnlock()
|
||||
// Upgrade to write lock for deletion
|
||||
c.mu.Lock()
|
||||
// Re-check in case another goroutine already deleted it
|
||||
if entry, ok := c.data[key]; ok && time.Now().After(entry.ExpiresAt) {
|
||||
delete(c.data, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.Value, true
|
||||
value := entry.Value
|
||||
c.mu.RUnlock()
|
||||
return value, true
|
||||
}
|
||||
|
||||
// Set stores a value in the cache
|
||||
|
||||
@@ -301,12 +301,16 @@ func isRetryableError(err error) bool {
|
||||
"timeout",
|
||||
"temporary failure",
|
||||
"server error",
|
||||
"stream error",
|
||||
"CANCEL",
|
||||
"EOF",
|
||||
"broken pipe",
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
}
|
||||
for _, msg := range retryableMessages {
|
||||
if strings.Contains(strings.ToLower(errStr), msg) {
|
||||
if strings.Contains(strings.ToLower(errStr), strings.ToLower(msg)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
@@ -39,9 +40,15 @@ func newProgressBar(label string, total int) *progressBar {
|
||||
|
||||
func (p *progressBar) update(fetched int) {
|
||||
p.current = fetched
|
||||
percent := float64(p.current) / float64(p.total)
|
||||
if percent > 1.0 {
|
||||
percent = 1.0
|
||||
// Guard against division by zero
|
||||
var percent float64
|
||||
if p.total > 0 {
|
||||
percent = float64(p.current) / float64(p.total)
|
||||
if percent > 1.0 {
|
||||
percent = 1.0
|
||||
}
|
||||
} else {
|
||||
percent = 0.0
|
||||
}
|
||||
|
||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
@@ -127,8 +134,28 @@ func fetchGQLPaginated[Q any, T any, R any](
|
||||
}
|
||||
|
||||
for {
|
||||
if err := client.Query(ctx, config.Query, variables); err != nil {
|
||||
return nil, fmt.Errorf("graphql query failed: %w", err)
|
||||
// Retry logic for transient errors
|
||||
var queryErr error
|
||||
for retries := 0; retries < 3; retries++ {
|
||||
queryErr = client.Query(ctx, config.Query, variables)
|
||||
if queryErr == nil {
|
||||
break
|
||||
}
|
||||
// Check if error is retryable
|
||||
if !isGQLRetryableError(queryErr) {
|
||||
break
|
||||
}
|
||||
// Wait before retry with exponential backoff
|
||||
backoff := time.Duration(1<<retries) * time.Second
|
||||
fmt.Fprintf(os.Stderr, "\r GraphQL retry %d/3 (waiting %s): %v\n", retries+1, backoff, queryErr)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
if queryErr != nil {
|
||||
return nil, fmt.Errorf("graphql query failed: %w", queryErr)
|
||||
}
|
||||
|
||||
page := config.GetPageResult(config.Query)
|
||||
@@ -514,3 +541,33 @@ func convertCommentNode(node gqlCommentNode, repoName string, issueNumber int) m
|
||||
CreatedAt: node.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// isGQLRetryableError checks if a GraphQL error is transient and should be retried
|
||||
func isGQLRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
retryablePatterns := []string{
|
||||
"stream error",
|
||||
"cancel",
|
||||
"eof",
|
||||
"connection reset",
|
||||
"connection refused",
|
||||
"timeout",
|
||||
"temporary failure",
|
||||
"broken pipe",
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
}
|
||||
|
||||
for _, pattern := range retryablePatterns {
|
||||
if strings.Contains(errStr, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
</head>
|
||||
<body class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 font-sans transition-colors duration-300">
|
||||
<body class="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 font-sans">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
Generated
+64
@@ -15,6 +15,7 @@
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
@@ -547,6 +548,22 @@
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
|
||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.50",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz",
|
||||
@@ -1829,6 +1846,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
|
||||
+2
-2
@@ -30,14 +30,14 @@ onMounted(async () => {
|
||||
<div v-if="loading" class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-primary-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">Loading dashboard...</p>
|
||||
<p class="text-gray-400">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-4xl text-red-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ error }}</p>
|
||||
<p class="text-gray-400">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -244,17 +244,17 @@ const sizeClasses = {
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-2 bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-xl opacity-0 group-hover/badge:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50 shadow-xl border border-white/10">
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-2 bg-gray-800 text-white text-xs rounded-xl opacity-0 group-hover/badge:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50 shadow-xl border border-white/10">
|
||||
<div class="font-bold text-sm">{{ getAchievement(achievementId).name }}</div>
|
||||
<div class="text-gray-300 text-[11px] mt-0.5">{{ getAchievement(achievementId).description }}</div>
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 border-[6px] border-transparent border-t-gray-900 dark:border-t-gray-800"></div>
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 border-[6px] border-transparent border-t-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Label (optional) - no truncation -->
|
||||
<span
|
||||
v-if="showLabel"
|
||||
class="text-[11px] font-medium text-gray-600 dark:text-gray-400 text-center leading-tight"
|
||||
class="text-[11px] font-medium text-gray-400 text-center leading-tight"
|
||||
style="max-width: 72px; word-wrap: break-word;"
|
||||
>
|
||||
{{ getAchievement(achievementId).name }}
|
||||
|
||||
@@ -255,7 +255,7 @@ const remainingCount = computed(() => {
|
||||
<div
|
||||
v-for="item in progressItems"
|
||||
:key="item.id"
|
||||
class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
class="bg-gray-800/50 rounded-xl p-4 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -266,30 +266,30 @@ const remainingCount = computed(() => {
|
||||
<i class="fas text-white text-sm" :class="item.icon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-800 dark:text-white">
|
||||
<div class="text-sm font-semibold text-white">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span>{{ item.category }}</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">•</span>
|
||||
<span class="text-gray-400">•</span>
|
||||
<span class="font-medium">Tier {{ item.tierIndex }}/{{ item.totalTiers }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-bold" :class="item.isClose ? 'text-green-500' : 'text-gray-700 dark:text-gray-200'">
|
||||
<div class="text-sm font-bold" :class="item.isClose ? 'text-green-500' : 'text-gray-200'">
|
||||
{{ formatNumber(item.currentValue) }}
|
||||
<span class="text-gray-400 dark:text-gray-500 font-normal">/</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 font-medium">{{ formatNumber(item.target) }}</span>
|
||||
<span class="text-gray-400 font-normal">/</span>
|
||||
<span class="text-gray-400 font-medium">{{ formatNumber(item.target) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
<div class="text-xs text-gray-400 mt-0.5">
|
||||
{{ item.remaining > 0 ? `${formatNumber(item.remaining)} to go` : 'Ready to claim!' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div class="h-2.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 ease-out"
|
||||
:class="item.progressColor"
|
||||
@@ -304,14 +304,14 @@ const remainingCount = computed(() => {
|
||||
v-for="(t, idx) in item.allTiers.slice(0, 5)"
|
||||
:key="t.threshold"
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="idx < item.tierIndex ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'"
|
||||
:class="idx < item.tierIndex ? 'bg-green-500' : 'bg-gray-600'"
|
||||
:title="`Tier ${idx + 1}: ${t.name} (${formatNumber(t.threshold)})`"
|
||||
></span>
|
||||
<span v-if="item.totalTiers > 5" class="text-[10px] text-gray-400">+{{ item.totalTiers - 5 }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-semibold"
|
||||
:class="item.isClose ? 'text-green-500' : 'text-gray-400 dark:text-gray-500'"
|
||||
:class="item.isClose ? 'text-green-500' : 'text-gray-400'"
|
||||
>
|
||||
{{ item.progress }}%
|
||||
</span>
|
||||
@@ -319,16 +319,16 @@ const remainingCount = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Show more indicator -->
|
||||
<div v-if="remainingCount > 0" class="text-center text-xs text-gray-500 dark:text-gray-400 pt-2">
|
||||
<div v-if="remainingCount > 0" class="text-center text-xs text-gray-400 pt-2">
|
||||
+{{ remainingCount }} more achievements to unlock
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!progressItems.length" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div v-if="!progressItems.length" class="text-center py-8 text-gray-400">
|
||||
<div class="w-16 h-16 mx-auto mb-3 rounded-2xl bg-gradient-to-br from-yellow-400 to-amber-500 flex items-center justify-center shadow-lg">
|
||||
<i class="fas fa-trophy text-2xl text-white"></i>
|
||||
</div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">All achievements unlocked!</p>
|
||||
<p class="font-medium text-gray-300">All achievements unlocked!</p>
|
||||
<p class="text-sm mt-1">You're a legend!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-400 mb-6">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<RouterLink
|
||||
v-if="item.to"
|
||||
@@ -22,7 +22,7 @@ defineProps({
|
||||
</RouterLink>
|
||||
<span
|
||||
v-else
|
||||
:class="index === items.length - 1 ? 'text-gray-800 dark:text-white' : ''"
|
||||
:class="index === items.length - 1 ? 'text-white' : ''"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
padding: { type: Boolean, default: true },
|
||||
hover: { type: Boolean, default: false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'rounded-xl bg-gray-800 text-white border border-gray-700 shadow',
|
||||
padding ? 'p-6' : '',
|
||||
hover ? 'hover:shadow-lg transition-shadow' : ''
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from './Card.vue'
|
||||
import Avatar from './Avatar.vue'
|
||||
import RankBadge from './RankBadge.vue'
|
||||
import AchievementBadge from './AchievementBadge.vue'
|
||||
@@ -16,63 +17,74 @@ defineProps({
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'contributor', params: { login: contributor.login } }"
|
||||
:class="[
|
||||
'card animate-fade-in-up block cursor-pointer hover:shadow-lg transition-shadow',
|
||||
featured && rank === 1 ? 'ring-2 ring-yellow-400' : ''
|
||||
]"
|
||||
class="block group"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="contributor.avatar_url"
|
||||
:name="contributor.login"
|
||||
:size="featured ? 'xl' : 'lg'"
|
||||
/>
|
||||
<RankBadge
|
||||
v-if="showRank && rank > 0"
|
||||
:rank="rank"
|
||||
size="sm"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition-colors">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span
|
||||
class="hover:text-primary-500 transition-colors"
|
||||
@click.stop.prevent="window.open(`https://github.com/${contributor.login}`, '_blank')"
|
||||
>
|
||||
@{{ contributor.login }}
|
||||
<i class="fas fa-external-link-alt text-xs ml-0.5 opacity-50"></i>
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="contributor.team" class="text-xs text-accent-500">{{ contributor.team }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold gradient-text">
|
||||
{{ formatNumber(contributor.score?.total || contributor.score || 0) }}
|
||||
<Card
|
||||
hover
|
||||
:class="[
|
||||
'animate-[fadeInUp_0.6s_ease-out] h-full',
|
||||
featured && rank === 1 ? 'ring-2 ring-yellow-400' : '',
|
||||
featured && rank === 2 ? 'ring-2 ring-gray-300' : '',
|
||||
featured && rank === 3 ? 'ring-2 ring-amber-600' : ''
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header with avatar and rank -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="contributor.avatar_url"
|
||||
:name="contributor.login"
|
||||
:size="featured ? 'xl' : 'lg'"
|
||||
class="ring-2 ring-gray-700"
|
||||
/>
|
||||
<RankBadge
|
||||
v-if="showRank && rank > 0"
|
||||
:rank="rank"
|
||||
size="sm"
|
||||
class="absolute -bottom-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-lg text-white group-hover:text-primary-500 transition-colors">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
@{{ contributor.login }}
|
||||
</p>
|
||||
<p v-if="contributor.team" class="text-xs text-accent-500 mt-0.5">{{ contributor.team }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">points</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="contributor.achievements?.length" class="mt-4 flex flex-wrap gap-1.5">
|
||||
<AchievementBadge
|
||||
v-for="achievement in contributor.achievements.slice(0, 6)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="contributor.achievements.length > 6"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ contributor.achievements.length - 6 }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Score display -->
|
||||
<div class="flex items-center justify-between py-3 px-4 -mx-2 rounded-lg bg-gradient-to-r from-primary-900/20 to-accent-900/20 mb-4">
|
||||
<span class="text-sm font-medium text-gray-300">Score</span>
|
||||
<span class="text-2xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ formatNumber(contributor.score?.total || contributor.score || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Achievements -->
|
||||
<div v-if="contributor.achievements?.length" class="mt-auto">
|
||||
<div class="text-xs font-medium text-gray-400 mb-2">Achievements</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<AchievementBadge
|
||||
v-for="achievement in contributor.achievements.slice(0, 8)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="contributor.achievements.length > 8"
|
||||
class="inline-flex items-center justify-center px-2 h-7 rounded-lg bg-gray-700 text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ contributor.achievements.length - 8 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -30,7 +30,7 @@ defineProps({
|
||||
class="ring-2 ring-transparent group-hover:ring-primary-500 transition-all"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-medium text-gray-800 dark:text-white group-hover:text-primary-500 transition-colors">
|
||||
<div class="font-medium text-white group-hover:text-primary-500 transition-colors">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
@@ -39,13 +39,13 @@ defineProps({
|
||||
:href="`https://github.com/${contributor.login}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-primary-500 transition-colors"
|
||||
class="font-medium text-gray-400 hover:text-primary-500 transition-colors"
|
||||
@click.stop
|
||||
>
|
||||
@{{ contributor.login }}
|
||||
<i class="fas fa-external-link-alt text-xs ml-1 opacity-50"></i>
|
||||
</a>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||
<span v-else class="font-medium text-gray-400">
|
||||
@{{ contributor.login }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import Card from './Card.vue'
|
||||
|
||||
defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
@@ -19,7 +21,7 @@ defineProps({
|
||||
},
|
||||
rowClass: {
|
||||
type: String,
|
||||
default: 'hover:bg-gray-50 dark:hover:bg-gray-800/30 transition'
|
||||
default: 'hover:bg-gray-800/30 transition'
|
||||
},
|
||||
clickableRows: {
|
||||
type: Boolean,
|
||||
@@ -39,15 +41,15 @@ const getAlignClass = (align) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card overflow-hidden p-0">
|
||||
<Card :padding="false" class="overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<thead class="bg-gray-800/50">
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
'px-6 py-4 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider',
|
||||
'px-3 sm:px-6 py-3 sm:py-4 text-xs font-semibold text-gray-400 uppercase tracking-wider',
|
||||
getAlignClass(col.align),
|
||||
col.headerClass
|
||||
]"
|
||||
@@ -56,7 +58,7 @@ const getAlignClass = (align) => {
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
<tr
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id || item.login || index"
|
||||
@@ -66,7 +68,7 @@ const getAlignClass = (align) => {
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="['px-6 py-4', getAlignClass(col.align), col.class]"
|
||||
:class="['px-3 sm:px-6 py-3 sm:py-4', getAlignClass(col.align), col.class]"
|
||||
>
|
||||
<slot :name="col.key" :item="item" :index="index">
|
||||
{{ item[col.key] }}
|
||||
@@ -78,8 +80,8 @@ const getAlignClass = (align) => {
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!items.length" class="text-center py-12">
|
||||
<i :class="emptyIcon" class="text-4xl text-gray-300 dark:text-gray-600 mb-4"></i>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ emptyMessage }}</p>
|
||||
<i :class="emptyIcon" class="text-4xl text-gray-600 mb-4"></i>
|
||||
<p class="text-gray-400">{{ emptyMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,7 @@ defineProps({
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i :class="icon" class="text-4xl text-red-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||
<p class="text-gray-400">{{ message }}</p>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,20 +14,20 @@ const generatedAt = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="py-8 px-4 mt-16 border-t border-gray-200 dark:border-gray-700">
|
||||
<footer class="py-8 px-4 mt-16 border-t border-gray-700">
|
||||
<div class="container mx-auto text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
<p class="text-gray-400">
|
||||
Generated by
|
||||
<a
|
||||
href="https://github.com/lukaszraczylo/git-velocity"
|
||||
class="text-primary-500 hover:text-primary-600"
|
||||
class="text-primary-400 hover:text-primary-300 font-medium"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Git Velocity
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="generatedAt" class="text-sm text-gray-400 dark:text-gray-500 mt-2">
|
||||
<p v-if="generatedAt" class="text-sm text-gray-500 mt-2">
|
||||
{{ generatedAt }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ defineProps({
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-primary-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||
<p class="text-gray-400">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from './Card.vue'
|
||||
import Avatar from './Avatar.vue'
|
||||
import AchievementBadge from './AchievementBadge.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
@@ -20,60 +21,62 @@ defineProps({
|
||||
<component
|
||||
:is="linkToProfile ? RouterLink : 'div'"
|
||||
:to="linkToProfile ? { name: 'contributor', params: { login: member.login } } : undefined"
|
||||
class="card block"
|
||||
:class="{ 'hover:shadow-lg transition cursor-pointer': linkToProfile }"
|
||||
class="block"
|
||||
:class="{ 'group': linkToProfile }"
|
||||
>
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<Avatar :src="member.avatar_url" :name="member.login" size="lg" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white">
|
||||
{{ member.name || member.login }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">@{{ member.login }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 text-center mb-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(member.commit_count) }}
|
||||
<Card :hover="linkToProfile" :class="{ 'cursor-pointer': linkToProfile }">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<Avatar :src="member.avatar_url" :name="member.login" size="lg" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-white">
|
||||
{{ member.name || member.login }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400">@{{ member.login }}</p>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Commits</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(member.prs_opened) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">PRs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(member.reviews_given) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Reviews</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Score</span>
|
||||
<span class="text-xl font-bold gradient-text">
|
||||
{{ formatNumber(member.score?.total || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4 text-center mb-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ formatNumber(member.commit_count) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Commits</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ formatNumber(member.prs_opened) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">PRs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ formatNumber(member.reviews_given) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Reviews</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="member.achievements?.length" class="mt-4 flex flex-wrap gap-2">
|
||||
<AchievementBadge
|
||||
v-for="achievement in member.achievements.slice(0, 4)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="member.achievements.length > 4"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ member.achievements.length - 4 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-700">
|
||||
<span class="text-sm text-gray-400">Score</span>
|
||||
<span class="text-xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ formatNumber(member.score?.total || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="member.achievements?.length" class="mt-4 flex flex-wrap gap-2">
|
||||
<AchievementBadge
|
||||
v-for="achievement in member.achievements.slice(0, 4)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="member.achievements.length > 4"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-700 text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ member.achievements.length - 4 }}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, inject, computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const globalData = inject('globalData')
|
||||
@@ -11,77 +10,106 @@ const repositories = computed(() => globalData.value?.Repositories || [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sticky top-0 z-50 glass shadow-modern">
|
||||
<nav class="sticky top-0 z-50 bg-gray-900/80 backdrop-blur-md border-b border-gray-700 shadow-lg">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" class="flex items-center space-x-2">
|
||||
<i class="fas fa-rocket text-2xl gradient-text"></i>
|
||||
<span class="text-xl font-bold gradient-text">Git Velocity</span>
|
||||
<i class="fas fa-rocket text-2xl bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent"></i>
|
||||
<span class="text-xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">Git Velocity</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center space-x-6">
|
||||
<RouterLink
|
||||
to="/"
|
||||
:class="route.path === '/' ? 'nav-link-active' : 'nav-link'"
|
||||
:class="route.path === '/' ? 'text-primary-500 font-medium' : 'text-gray-200 font-medium hover:text-primary-400 transition-colors'"
|
||||
>
|
||||
Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/leaderboard"
|
||||
:class="route.path === '/leaderboard' ? 'nav-link-active' : 'nav-link'"
|
||||
:class="route.path === '/leaderboard' ? 'text-primary-500 font-medium' : 'text-gray-200 font-medium hover:text-primary-400 transition-colors'"
|
||||
>
|
||||
Leaderboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/how-scoring-works"
|
||||
:class="route.path === '/how-scoring-works' ? 'text-primary-500 font-medium' : 'text-gray-200 font-medium hover:text-primary-400 transition-colors'"
|
||||
>
|
||||
How Scoring Works
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-for="repo in repositories"
|
||||
:key="`${repo.Owner}/${repo.Name}`"
|
||||
:to="`/repos/${repo.Owner}/${repo.Name}`"
|
||||
:class="route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) ? 'nav-link-active' : 'nav-link'"
|
||||
:class="route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) ? 'text-primary-500 font-medium' : 'text-gray-200 font-medium hover:text-primary-400 transition-colors'"
|
||||
>
|
||||
{{ repo.Name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<ThemeToggle />
|
||||
|
||||
<button
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
class="md:hidden p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||
>
|
||||
<i class="fas fa-bars text-gray-700 dark:text-gray-200"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
class="md:hidden p-2 rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
<i class="fas fa-bars text-gray-200"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div v-if="mobileMenuOpen" class="md:hidden py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div v-if="mobileMenuOpen" class="md:hidden py-2 border-t border-gray-700">
|
||||
<div class="flex flex-col space-y-1">
|
||||
<RouterLink
|
||||
to="/"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="route.path === '/' ? 'nav-link-active' : 'nav-link'"
|
||||
: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'
|
||||
]"
|
||||
>
|
||||
Dashboard
|
||||
<i class="fas fa-home mr-3 w-5 text-center"></i>Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/leaderboard"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="route.path === '/leaderboard' ? 'nav-link-active' : 'nav-link'"
|
||||
: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'
|
||||
]"
|
||||
>
|
||||
Leaderboard
|
||||
<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'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-calculator mr-3 w-5 text-center"></i>How Scoring Works
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-for="repo in repositories"
|
||||
:key="`${repo.Owner}/${repo.Name}`"
|
||||
:to="`/repos/${repo.Owner}/${repo.Name}`"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) ? 'nav-link-active' : 'nav-link'"
|
||||
: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'
|
||||
]"
|
||||
>
|
||||
{{ repo.Name }}
|
||||
<i class="fas fa-code-branch mr-3 w-5 text-center"></i>{{ repo.Name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,11 +38,11 @@ defineProps({
|
||||
<slot name="prefix"></slot>
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
<i v-if="icon" :class="[icon, iconColor]" class="mr-3"></i>
|
||||
<span class="gradient-text">{{ title }}</span>
|
||||
<span class="bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">{{ title }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p v-if="subtitle || $slots.subtitle" class="text-gray-600 dark:text-gray-300">
|
||||
<p v-if="subtitle || $slots.subtitle" class="text-gray-300">
|
||||
<slot name="subtitle">{{ subtitle }}</slot>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -8,17 +8,20 @@ const props = defineProps({
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6 text-xs',
|
||||
md: 'w-8 h-8 text-sm'
|
||||
md: 'w-8 h-8 text-sm',
|
||||
lg: 'w-10 h-10 text-base'
|
||||
}
|
||||
|
||||
const rankClass = computed(() => {
|
||||
if (props.rank === 1) return 'rank-1'
|
||||
if (props.rank === 2) return 'rank-2'
|
||||
if (props.rank === 3) return 'rank-3'
|
||||
return 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
if (props.rank === 1) return 'bg-gradient-to-r from-yellow-400 to-amber-500'
|
||||
if (props.rank === 2) return 'bg-gradient-to-r from-slate-400 to-slate-500'
|
||||
if (props.rank === 3) return 'bg-gradient-to-r from-amber-600 to-amber-700'
|
||||
return 'bg-gray-700 text-gray-300'
|
||||
})
|
||||
|
||||
const classes = computed(() => sizeClasses[props.size] || sizeClasses.md)
|
||||
|
||||
const isTopThree = computed(() => props.rank >= 1 && props.rank <= 3)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,6 +29,7 @@ const classes = computed(() => sizeClasses[props.size] || sizeClasses.md)
|
||||
:class="[classes, rankClass, { 'text-white': rank <= 3 }]"
|
||||
class="inline-flex items-center justify-center rounded-full font-bold"
|
||||
>
|
||||
{{ rank }}
|
||||
<i v-if="isTopThree" class="fas fa-trophy"></i>
|
||||
<template v-else>{{ rank }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from './Card.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
@@ -13,35 +14,37 @@ defineProps({
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.owner}/${repo.name}`"
|
||||
class="card hover:shadow-lg transition group"
|
||||
class="block group"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition">
|
||||
{{ repo.name }}
|
||||
</h3>
|
||||
<i class="fas fa-arrow-right text-gray-400 group-hover:text-primary-500 transition"></i>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{ repo.owner }}/{{ repo.name }}</p>
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-white group-hover:text-primary-500 transition">
|
||||
{{ repo.name }}
|
||||
</h3>
|
||||
<i class="fas fa-arrow-right text-gray-400 group-hover:text-primary-500 transition"></i>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-4">{{ repo.owner }}/{{ repo.name }}</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(repo.total_commits) }}
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ formatNumber(repo.total_commits) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Commits</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Commits</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(repo.total_prs) }}
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ formatNumber(repo.total_prs) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">PRs</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">PRs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ repo.active_contributors }}
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ repo.active_contributors }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Contributors</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Contributors</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +16,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">
|
||||
<i v-if="icon" :class="[icon, iconColor]" class="mr-2"></i>{{ title }}
|
||||
<slot name="suffix"></slot>
|
||||
</h2>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import Card from './Card.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
@@ -6,23 +7,23 @@ defineProps({
|
||||
label: { type: String, required: true },
|
||||
icon: { type: String, default: '' },
|
||||
iconColor: { type: String, default: 'text-gray-500' },
|
||||
valueClass: { type: String, default: 'gradient-text' },
|
||||
valueClass: { type: String, default: 'bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent' },
|
||||
delay: { type: String, default: '0s' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card animate-fade-in-up" :style="{ animationDelay: delay }">
|
||||
<Card class="animate-[fadeInUp_0.6s_ease-out]" :style="{ animationDelay: delay }">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-3xl font-bold" :class="valueClass">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xl sm:text-2xl md:text-3xl font-bold truncate" :class="valueClass">
|
||||
{{ typeof value === 'number' ? formatNumber(value) : value }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ label }}</div>
|
||||
<div class="text-xs sm:text-sm text-gray-400 mt-1 truncate">{{ label }}</div>
|
||||
</div>
|
||||
<div v-if="icon" class="text-3xl opacity-50" :class="iconColor">
|
||||
<div v-if="icon" class="text-2xl sm:text-3xl opacity-50 ml-2 flex-shrink-0" :class="iconColor">
|
||||
<i :class="icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from './Card.vue'
|
||||
import Avatar from './Avatar.vue'
|
||||
import { formatNumber, slugify } from '../composables/formatters'
|
||||
|
||||
@@ -14,43 +15,45 @@ defineProps({
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/teams/${slugify(team.name)}`"
|
||||
class="card hover:shadow-lg transition group"
|
||||
class="block group"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition">
|
||||
{{ team.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: team.color || '#8b5cf6' }"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 mb-4">
|
||||
<template v-for="(member, i) in (team.members || []).slice(0, 5)" :key="member">
|
||||
<Avatar :name="member" size="sm" />
|
||||
</template>
|
||||
<span
|
||||
v-if="(team.members?.length || 0) > 5"
|
||||
class="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-gray-600 dark:text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ team.members.length - 5 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold gradient-text">
|
||||
{{ formatNumber(team.total_score) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Total Score</div>
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-white group-hover:text-primary-500 transition">
|
||||
{{ team.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: team.color || '#8b5cf6' }"
|
||||
></span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ team.members?.length || 0 }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Members</div>
|
||||
|
||||
<div class="flex items-center space-x-2 mb-4">
|
||||
<template v-for="(member, i) in (team.members || []).slice(0, 5)" :key="member">
|
||||
<Avatar :name="member" size="sm" />
|
||||
</template>
|
||||
<span
|
||||
v-if="(team.members?.length || 0) > 5"
|
||||
class="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ team.members.length - 5 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ formatNumber(team.total_score) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Total Score</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ team.members?.length || 0 }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Members</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
const isDark = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
isDark.value = savedTheme === 'dark' || (!savedTheme && prefersDark)
|
||||
updateTheme()
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
isDark.value = e.matches
|
||||
updateTheme()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
updateTheme()
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
})
|
||||
|
||||
function updateTheme() {
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
@click="toggle"
|
||||
class="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
>
|
||||
<i v-if="isDark" class="fas fa-moon text-purple-400"></i>
|
||||
<i v-else class="fas fa-sun text-yellow-500"></i>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
@@ -49,7 +49,18 @@ const chartData = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = {
|
||||
const isMobile = ref(window.innerWidth < 640)
|
||||
|
||||
// Dark mode colors
|
||||
const themeColors = {
|
||||
gridColor: 'rgba(255, 255, 255, 0.1)',
|
||||
textColor: 'rgba(255, 255, 255, 0.7)',
|
||||
tooltipBg: 'rgba(30, 30, 30, 0.95)',
|
||||
tooltipText: '#fff',
|
||||
tooltipBorder: 'rgba(255, 255, 255, 0.1)'
|
||||
}
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
@@ -61,20 +72,26 @@ const chartOptions = {
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
padding: isMobile.value ? 10 : 20,
|
||||
boxWidth: isMobile.value ? 8 : 12,
|
||||
color: themeColors.textColor,
|
||||
font: {
|
||||
size: 12
|
||||
size: isMobile.value ? 10 : 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
backgroundColor: themeColors.tooltipBg,
|
||||
titleColor: themeColors.tooltipText,
|
||||
bodyColor: themeColors.tooltipText,
|
||||
borderColor: themeColors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
padding: isMobile.value ? 8 : 12,
|
||||
titleFont: {
|
||||
size: 14
|
||||
size: isMobile.value ? 12 : 14
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13
|
||||
size: isMobile.value ? 11 : 13
|
||||
},
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
@@ -89,19 +106,24 @@ const chartOptions = {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: themeColors.textColor,
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
size: isMobile.value ? 9 : 11
|
||||
},
|
||||
maxRotation: isMobile.value ? 45 : 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: isMobile.value ? 6 : 12
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)'
|
||||
color: themeColors.gridColor
|
||||
},
|
||||
ticks: {
|
||||
color: themeColors.textColor,
|
||||
font: {
|
||||
size: 11
|
||||
size: isMobile.value ? 9 : 11
|
||||
},
|
||||
callback: (value) => {
|
||||
if (value >= 1000) {
|
||||
@@ -112,7 +134,7 @@ const chartOptions = {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value || !chartData.value.labels.length) return
|
||||
@@ -125,7 +147,7 @@ function createChart() {
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData.value,
|
||||
options: chartOptions
|
||||
options: chartOptions.value
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,8 +160,24 @@ function updateChart() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
const newIsMobile = window.innerWidth < 640
|
||||
if (newIsMobile !== isMobile.value) {
|
||||
isMobile.value = newIsMobile
|
||||
createChart() // Recreate chart with new options
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
createChart()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.timeline, () => {
|
||||
|
||||
@@ -9,10 +9,12 @@ import Leaderboard from './views/Leaderboard.vue'
|
||||
import Repository from './views/Repository.vue'
|
||||
import Team from './views/Team.vue'
|
||||
import Contributor from './views/Contributor.vue'
|
||||
import HowScoringWorks from './views/HowScoringWorks.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||
{ path: '/leaderboard', name: 'leaderboard', component: Leaderboard },
|
||||
{ path: '/how-scoring-works', name: 'how-scoring-works', component: HowScoringWorks },
|
||||
{ path: '/repos/:owner/:name', name: 'repository', component: Repository },
|
||||
{ path: '/teams/:slug', name: 'team', component: Team },
|
||||
{ path: '/contributors/:login', name: 'contributor', component: Contributor },
|
||||
|
||||
@@ -26,123 +26,8 @@
|
||||
--color-accent-800: #6b21a8;
|
||||
--color-accent-900: #581c87;
|
||||
|
||||
--animate-fade-in-up: fadeInUp 0.6s ease-out;
|
||||
--animate-float: float 3s ease-in-out infinite;
|
||||
|
||||
@keyframes fadeInUp {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
}
|
||||
|
||||
@utility glass {
|
||||
background-color: rgb(255 255 255 / 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgb(255 255 255 / 0.2);
|
||||
}
|
||||
|
||||
.dark .glass {
|
||||
background-color: rgb(17 24 39 / 0.7);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
@utility gradient-text {
|
||||
background-image: linear-gradient(to right, var(--color-primary-400), var(--color-accent-400));
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
@utility shadow-modern {
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dark .shadow-modern {
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@utility score-card {
|
||||
background-image: linear-gradient(to right, rgb(244 114 182 / 0.1), rgb(192 132 252 / 0.1));
|
||||
border: 1px solid rgb(244 114 182 / 0.2);
|
||||
}
|
||||
|
||||
.dark .score-card {
|
||||
background-image: linear-gradient(to right, rgb(244 114 182 / 0.05), rgb(192 132 252 / 0.05));
|
||||
border-color: rgb(244 114 182 / 0.1);
|
||||
}
|
||||
|
||||
@utility rank-1 {
|
||||
background-image: linear-gradient(to right, #facc15, #f59e0b);
|
||||
}
|
||||
|
||||
@utility rank-2 {
|
||||
background-image: linear-gradient(to right, #94a3b8, #64748b);
|
||||
}
|
||||
|
||||
@utility rank-3 {
|
||||
background-image: linear-gradient(to right, #d97706, #b45309);
|
||||
}
|
||||
|
||||
@utility achievement-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
background-image: linear-gradient(to right, var(--color-primary-400), var(--color-accent-400));
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
@utility btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-image: linear-gradient(to right, var(--color-primary-500), var(--color-accent-500));
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-image: linear-gradient(to right, var(--color-primary-600), var(--color-accent-600));
|
||||
}
|
||||
|
||||
@utility card {
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@utility nav-link {
|
||||
color: #374151;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--color-primary-500);
|
||||
}
|
||||
|
||||
.dark .nav-link {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .nav-link:hover {
|
||||
color: var(--color-primary-400);
|
||||
}
|
||||
|
||||
@utility nav-link-active {
|
||||
color: var(--color-primary-500);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@utility animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, inject } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import Card from '../components/Card.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import LoadingState from '../components/LoadingState.vue'
|
||||
import ErrorState from '../components/ErrorState.vue'
|
||||
@@ -98,27 +99,27 @@ watch(globalData, loadContributor)
|
||||
:src="contributor.avatar_url"
|
||||
:name="contributor.login"
|
||||
size="2xl"
|
||||
class="shadow-modern"
|
||||
class="shadow-lg"
|
||||
/>
|
||||
|
||||
<div class="text-center md:text-left">
|
||||
<h1 class="text-4xl font-bold gradient-text">
|
||||
<h1 class="text-4xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</h1>
|
||||
<p class="text-xl text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p class="text-xl 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">
|
||||
<div class="bg-gradient-to-r from-pink-400/5 to-purple-400/5 border border-pink-400/10 rounded-lg px-4 py-2">
|
||||
<span class="text-sm text-gray-400">Score:</span>
|
||||
<span class="text-2xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent 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">
|
||||
<div v-if="contributor.score?.rank" class="text-sm text-gray-400">
|
||||
Rank #{{ contributor.score.rank }}
|
||||
<span v-if="contributor.score?.percentile_rank">
|
||||
(Top {{ formatPercent(contributor.score.percentile_rank) }})
|
||||
@@ -177,136 +178,136 @@ watch(globalData, loadContributor)
|
||||
<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">
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold 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-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-gray-300">Lines Deleted</span>
|
||||
<span class="text-red-500 font-semibold">
|
||||
-{{ formatNumber(contributor.lines_deleted || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.meaningful_lines_added !== undefined" class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Meaningful Lines Added</span>
|
||||
<span class="text-gray-300">Meaningful Lines Added</span>
|
||||
<span class="text-emerald-500 font-semibold">
|
||||
+{{ formatNumber(contributor.meaningful_lines_added || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.meaningful_lines_deleted !== undefined" class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Meaningful Lines Deleted</span>
|
||||
<span class="text-gray-300">Meaningful Lines Deleted</span>
|
||||
<span class="text-rose-500 font-semibold">
|
||||
-{{ formatNumber(contributor.meaningful_lines_deleted || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.comment_lines_added !== undefined" class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Comment Lines Added</span>
|
||||
<span class="text-gray-300">Comment Lines Added</span>
|
||||
<span class="text-cyan-500 font-semibold">
|
||||
+{{ formatNumber(contributor.comment_lines_added || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.comment_lines_deleted !== undefined" class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Comment Lines Deleted</span>
|
||||
<span class="text-gray-300">Comment Lines Deleted</span>
|
||||
<span class="text-amber-500 font-semibold">
|
||||
-{{ formatNumber(contributor.comment_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">
|
||||
<span class="text-gray-300">Files Changed</span>
|
||||
<span class="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">
|
||||
<span class="text-gray-300">Avg PR Size</span>
|
||||
<span class="text-white font-semibold">
|
||||
{{ formatNumber(Math.round(contributor.avg_pr_size)) }} lines
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Review Stats -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold 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">
|
||||
<span class="text-gray-300">Reviews Given</span>
|
||||
<span class="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-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-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">
|
||||
<span class="text-gray-300">Review Comments</span>
|
||||
<span class="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">
|
||||
<span class="text-gray-300">Avg Review Time</span>
|
||||
<span class="text-white font-semibold">
|
||||
{{ formatDuration(contributor.avg_review_time_hours) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Issue Stats -->
|
||||
<div v-if="contributor.issues_opened || contributor.issues_closed || contributor.issue_comments || contributor.issue_references_in_commits" class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<Card v-if="contributor.issues_opened || contributor.issues_closed || contributor.issue_comments || contributor.issue_references_in_commits">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
<i class="fas fa-bug text-red-500 mr-2"></i>Issue Activity
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Issues Opened</span>
|
||||
<span class="text-gray-300">Issues Opened</span>
|
||||
<span class="text-red-500 font-semibold">
|
||||
{{ formatNumber(contributor.issues_opened || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Issues Closed</span>
|
||||
<span class="text-gray-300">Issues Closed</span>
|
||||
<span class="text-green-500 font-semibold">
|
||||
{{ formatNumber(contributor.issues_closed || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Issue Comments</span>
|
||||
<span class="text-gray-300">Issue Comments</span>
|
||||
<span class="text-blue-500 font-semibold">
|
||||
{{ formatNumber(contributor.issue_comments || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Issue References in Commits</span>
|
||||
<span class="text-gray-300">Issue References in Commits</span>
|
||||
<span class="text-purple-500 font-semibold">
|
||||
{{ formatNumber(contributor.issue_references_in_commits || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -314,70 +315,70 @@ watch(globalData, loadContributor)
|
||||
<!-- 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
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
<i class="fas fa-chart-pie bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent mr-2"></i>Score Breakdown
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4">
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg 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 class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.commit_count || 0 }} × 10 pts</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Commits</div>
|
||||
<div class="text-xs text-gray-400">{{ contributor.commit_count || 0 }} × 10 pts</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg 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 class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.prs_opened || 0 }} opened + {{ contributor.prs_merged || 0 }} merged</div>
|
||||
<div class="text-xs text-gray-400 mt-1">PRs</div>
|
||||
<div class="text-xs text-gray-400">{{ contributor.prs_opened || 0 }} opened + {{ contributor.prs_merged || 0 }} merged</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg 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 class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.reviews_given || 0 }} × 30 pts</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Reviews</div>
|
||||
<div class="text-xs text-gray-400">{{ contributor.reviews_given || 0 }} × 30 pts</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-pink-500">
|
||||
{{ formatNumber(contributor.score.breakdown.comments || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Comments</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.review_comments || 0 }} × 5 pts</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Comments</div>
|
||||
<div class="text-xs text-gray-400">{{ contributor.review_comments || 0 }} × 5 pts</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-red-500">
|
||||
{{ formatNumber(contributor.score.breakdown.issues || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Issues</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">opened, closed, comments, refs</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Issues</div>
|
||||
<div class="text-xs text-gray-400">opened, closed, comments, refs</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg 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 class="text-xs text-gray-400 dark:text-gray-500">meaningful lines × 0.1 pts</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Line Changes</div>
|
||||
<div class="text-xs text-gray-400">meaningful lines × 0.1 pts</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg 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 class="text-xs text-gray-400 dark:text-gray-500">fast review bonus</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Response Bonus</div>
|
||||
<div class="text-xs text-gray-400">fast review bonus</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-indigo-500">
|
||||
{{ formatNumber(contributor.score.breakdown.out_of_hours || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Out of Hours</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.out_of_hours_count || 0 }} × 2 pts</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Out of Hours</div>
|
||||
<div class="text-xs text-gray-400">{{ contributor.out_of_hours_count || 0 }} × 2 pts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -386,10 +387,10 @@ watch(globalData, loadContributor)
|
||||
<div class="container mx-auto">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Earned Achievements -->
|
||||
<div v-if="contributor.achievements?.length" class="card">
|
||||
<Card v-if="contributor.achievements?.length">
|
||||
<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 class="text-lg font-semibold text-white">
|
||||
<i class="fas fa-award bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent 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 }}
|
||||
@@ -400,7 +401,7 @@ watch(globalData, loadContributor)
|
||||
<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"
|
||||
class="flex flex-col items-center p-2 rounded-xl bg-gray-800/50 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<AchievementBadge
|
||||
:achievement-id="achievement"
|
||||
@@ -409,11 +410,11 @@ watch(globalData, loadContributor)
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Progress to Next Achievements -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold text-white mb-6">
|
||||
<i class="fas fa-chart-line text-primary-500 mr-2"></i>Next Achievements
|
||||
</h3>
|
||||
|
||||
@@ -421,7 +422,7 @@ watch(globalData, loadContributor)
|
||||
:contributor="contributor"
|
||||
:max-display="6"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -440,7 +441,7 @@ watch(globalData, loadContributor)
|
||||
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"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm bg-gray-800 text-gray-300 hover:bg-primary-900/30 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
<i class="fas fa-code-branch text-gray-400 mr-2"></i>
|
||||
{{ repo }}
|
||||
|
||||
+17
-14
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { inject, computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from '../components/Card.vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ContributorCard from '../components/ContributorCard.vue'
|
||||
import RepoCard from '../components/RepoCard.vue'
|
||||
@@ -23,16 +24,16 @@ const showScoreInChart = ref(false)
|
||||
<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>
|
||||
<header class="py-10 sm:py-16 px-4">
|
||||
<div class="container mx-auto text-center animate-[fadeInUp_0.6s_ease-out]">
|
||||
<h1 class="text-3xl sm:text-4xl md:text-6xl font-bold mb-3 sm:mb-4">
|
||||
<span class="bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">Git Velocity</span>
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
<p class="text-base sm:text-xl text-gray-300 max-w-2xl mx-auto px-2">
|
||||
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">
|
||||
<div class="flex flex-col items-center space-y-2 mt-4 text-sm 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>
|
||||
@@ -49,22 +50,24 @@ const showScoreInChart = ref(false)
|
||||
</header>
|
||||
|
||||
<!-- Velocity Timeline Chart -->
|
||||
<section v-if="velocityTimeline" class="py-8 px-4">
|
||||
<section v-if="velocityTimeline" class="py-6 sm:py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<Card>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4 sm: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">
|
||||
<label class="flex items-center space-x-2 text-sm text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showScoreInChart"
|
||||
class="rounded border-gray-300 text-primary-500 focus:ring-primary-500"
|
||||
class="rounded border-gray-600 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span>Show Score</span>
|
||||
</label>
|
||||
</div>
|
||||
<VelocityChart :timeline="velocityTimeline" :show-score="showScoreInChart" height="320px" />
|
||||
</div>
|
||||
<div class="h-[200px] sm:h-[280px] md:h-[320px]">
|
||||
<VelocityChart :timeline="velocityTimeline" :show-score="showScoreInChart" height="100%" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -124,7 +127,7 @@ const showScoreInChart = ref(false)
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<RouterLink to="/leaderboard" class="btn-primary">
|
||||
<RouterLink to="/leaderboard" class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-primary-500 to-accent-500 text-white font-medium rounded-lg shadow-lg hover:from-primary-600 hover:to-accent-600 transition-all">
|
||||
View Full Leaderboard
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</RouterLink>
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
<script setup>
|
||||
import Card from '../components/Card.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero Section -->
|
||||
<header class="py-10 sm:py-16 px-4">
|
||||
<div class="container mx-auto text-center animate-[fadeInUp_0.6s_ease-out]">
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl font-bold mb-3 sm:mb-4 text-white">
|
||||
How <span class="bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">Scoring</span> Works
|
||||
</h1>
|
||||
<p class="text-base sm:text-lg md:text-xl text-gray-300 max-w-2xl mx-auto px-2">
|
||||
Understanding the point system, leaderboard rankings, and achievement criteria that power Git Velocity.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Overview Section -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<Card class="shadow-lg mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-info-circle mr-3 text-blue-500"></i>
|
||||
Overview
|
||||
</h2>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Git Velocity calculates developer contributions by analyzing GitHub activity across configured repositories.
|
||||
The scoring system is designed to encourage well-rounded contributions including code commits, pull requests,
|
||||
code reviews, and collaboration.
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-3 gap-4 mt-6">
|
||||
<div class="text-center p-4 bg-primary-900/20 rounded-lg">
|
||||
<i class="fas fa-calculator text-primary-500 text-2xl mb-2"></i>
|
||||
<h3 class="font-medium text-gray-100">Point-Based</h3>
|
||||
<p class="text-sm text-gray-400">Activities earn configurable points</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-accent-900/20 rounded-lg">
|
||||
<i class="fas fa-layer-group text-accent-500 text-2xl mb-2"></i>
|
||||
<h3 class="font-medium text-gray-100">Aggregated</h3>
|
||||
<p class="text-sm text-gray-400">Combined across all repositories</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-indigo-900/20 rounded-lg">
|
||||
<i class="fas fa-trophy text-indigo-500 text-2xl mb-2"></i>
|
||||
<h3 class="font-medium text-gray-100">Achievement-Driven</h3>
|
||||
<p class="text-sm text-gray-400">Unlock badges for milestones</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scoring Section -->
|
||||
<section id="scoring" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Point Calculations" icon="fas fa-coins" icon-color="text-yellow-500" />
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Score Formula -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-lg sm:text-xl">
|
||||
<i class="fas fa-function mr-2 text-primary-500"></i>
|
||||
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 + Bonuses
|
||||
|
||||
Where:
|
||||
Commits = commit_count x 10 pts
|
||||
Lines = (added x 0.1) + (deleted x 0.05) pts
|
||||
PRs = (opened x 25) + (merged x 50) pts
|
||||
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
|
||||
Response = fast review bonus (0-50 pts)
|
||||
Out of Hrs = commits outside 9-5 x 2 pts</code></pre>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
All point values are configurable in your <code class="text-primary-400">.git-velocity.yaml</code> file.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Default Point Values -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-coins mr-2 text-yellow-500"></i>
|
||||
Default Point Values
|
||||
</h3>
|
||||
<!-- Mobile: Card Layout -->
|
||||
<div class="grid grid-cols-1 gap-3 sm:hidden">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-code-commit text-primary-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Commit</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">10 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-flask text-green-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Commit + Tests</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">15 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-plus text-blue-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Lines Added</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">0.1 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-minus text-red-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Lines Deleted</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">0.05 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-code-pull-request text-accent-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">PR Opened</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">25 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-code-merge text-indigo-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">PR Merged</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">50 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-eye text-cyan-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">PR Reviewed</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">30 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-comment text-orange-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Review Comment</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">5 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-bolt text-yellow-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Fast Review <1h</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">50 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-stopwatch text-yellow-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Fast Review <4h</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">25 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-clock text-yellow-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Fast Review <24h</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">10 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-moon text-gray-400"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Out of Hours</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">2 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-circle-exclamation text-teal-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Issue Opened</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">10 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-circle-check text-green-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Issue Closed</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">20 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-comment-dots text-blue-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Issue Comment</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">5 pts</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-link text-accent-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Issue Reference</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">5 pts</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Desktop: Table Layout -->
|
||||
<div class="hidden sm:block overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700">
|
||||
<th class="text-left py-3 text-gray-400">Activity</th>
|
||||
<th class="text-left py-3 text-gray-400">Points</th>
|
||||
<th class="text-left py-3 text-gray-400">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-300">
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-code-commit text-primary-500 mr-2"></i>Commit</td>
|
||||
<td class="py-3 font-mono text-primary-400">10</td>
|
||||
<td class="py-3">Per commit pushed</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-flask text-green-500 mr-2"></i>Commit with Tests</td>
|
||||
<td class="py-3 font-mono text-primary-400">15</td>
|
||||
<td class="py-3">Commit that includes test files</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-plus text-blue-500 mr-2"></i>Lines Added</td>
|
||||
<td class="py-3 font-mono text-primary-400">0.1</td>
|
||||
<td class="py-3">Per meaningful line added</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-minus text-red-500 mr-2"></i>Lines Deleted</td>
|
||||
<td class="py-3 font-mono text-primary-400">0.05</td>
|
||||
<td class="py-3">Per meaningful line removed</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-code-pull-request text-accent-500 mr-2"></i>PR Opened</td>
|
||||
<td class="py-3 font-mono text-primary-400">25</td>
|
||||
<td class="py-3">Per pull request created</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-code-merge text-indigo-500 mr-2"></i>PR Merged</td>
|
||||
<td class="py-3 font-mono text-primary-400">50</td>
|
||||
<td class="py-3">Per pull request merged</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-eye text-cyan-500 mr-2"></i>PR Reviewed</td>
|
||||
<td class="py-3 font-mono text-primary-400">30</td>
|
||||
<td class="py-3">Per PR review submitted</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-comment text-orange-500 mr-2"></i>Review Comment</td>
|
||||
<td class="py-3 font-mono text-primary-400">5</td>
|
||||
<td class="py-3">Per comment on PR reviews</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-bolt text-yellow-500 mr-2"></i>Fast Review (<1h)</td>
|
||||
<td class="py-3 font-mono text-primary-400">50</td>
|
||||
<td class="py-3">Bonus for average response under 1 hour</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-stopwatch text-yellow-500 mr-2"></i>Fast Review (<4h)</td>
|
||||
<td class="py-3 font-mono text-primary-400">25</td>
|
||||
<td class="py-3">Bonus for average response under 4 hours</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-clock text-yellow-500 mr-2"></i>Fast Review (<24h)</td>
|
||||
<td class="py-3 font-mono text-primary-400">10</td>
|
||||
<td class="py-3">Bonus for average response under 24 hours</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-moon text-gray-500 mr-2"></i>Out of Hours</td>
|
||||
<td class="py-3 font-mono text-primary-400">2</td>
|
||||
<td class="py-3">Per commit outside 9am-5pm</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-circle-exclamation text-teal-500 mr-2"></i>Issue Opened</td>
|
||||
<td class="py-3 font-mono text-primary-400">10</td>
|
||||
<td class="py-3">Per issue created</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-circle-check text-green-500 mr-2"></i>Issue Closed</td>
|
||||
<td class="py-3 font-mono text-primary-400">20</td>
|
||||
<td class="py-3">Per issue resolved/closed</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-comment-dots text-blue-500 mr-2"></i>Issue Comment</td>
|
||||
<td class="py-3 font-mono text-primary-400">5</td>
|
||||
<td class="py-3">Per comment on issues</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3"><i class="fas fa-link text-accent-500 mr-2"></i>Issue Reference</td>
|
||||
<td class="py-3 font-mono text-primary-400">5</td>
|
||||
<td class="py-3">Per commit referencing an issue (#123)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Meaningful Lines -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-filter mr-2 text-green-500"></i>
|
||||
Meaningful Lines
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
By default, Git Velocity uses <strong>meaningful lines</strong> instead of raw line counts.
|
||||
This filters out noise and rewards actual code contributions:
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div class="p-4 bg-green-900/20 rounded-lg">
|
||||
<h4 class="font-medium text-green-400 mb-2">
|
||||
<i class="fas fa-check mr-2"></i>Counted as Meaningful
|
||||
</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li>Actual code logic</li>
|
||||
<li>Function definitions</li>
|
||||
<li>Variable declarations</li>
|
||||
<li>Import statements</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-red-900/20 rounded-lg">
|
||||
<h4 class="font-medium text-red-400 mb-2">
|
||||
<i class="fas fa-times mr-2"></i>Filtered Out
|
||||
</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li>Empty lines / whitespace</li>
|
||||
<li>Single-line comments</li>
|
||||
<li>Multi-line comment blocks</li>
|
||||
<li>Documentation strings</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mt-4">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Meaningful lines filtering is always enabled to accurately reflect code contributions.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Leaderboard Section -->
|
||||
<section id="leaderboard-info" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Leaderboard Rankings" icon="fas fa-list-ol" icon-color="text-accent-500" />
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Ranking Process -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-list-ol mr-2 text-accent-500"></i>
|
||||
Ranking Process
|
||||
</h3>
|
||||
<ol class="space-y-4">
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-primary-900/30 flex items-center justify-center text-primary-400 font-bold">1</span>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-100">Aggregate Across Repos</h4>
|
||||
<p class="text-sm text-gray-400">Metrics from all configured repositories are combined per contributor</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-accent-900/30 flex items-center justify-center text-accent-400 font-bold">2</span>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-100">Calculate Total Score</h4>
|
||||
<p class="text-sm text-gray-400">Apply point values to each activity type and sum the breakdown</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-indigo-900/30 flex items-center justify-center text-indigo-400 font-bold">3</span>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-100">Sort by Score</h4>
|
||||
<p class="text-sm text-gray-400">Contributors are sorted in descending order by total score</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-900/30 flex items-center justify-center text-blue-400 font-bold">4</span>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-100">Assign Ranks & Percentiles</h4>
|
||||
<p class="text-sm text-gray-400">Each contributor receives a rank (1st, 2nd...) and percentile position</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</Card>
|
||||
|
||||
<!-- Top Categories -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-medal mr-2 text-yellow-500"></i>
|
||||
Top Achievers
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Git Velocity tracks top performers in each category:
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div class="p-4 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="fas fa-trophy text-yellow-500"></i>
|
||||
<span class="font-medium text-gray-100">Overall Leader</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">Highest total score</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="fas fa-code-commit text-primary-500"></i>
|
||||
<span class="font-medium text-gray-100">Top Committer</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">Most commits</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="fas fa-eye text-accent-500"></i>
|
||||
<span class="font-medium text-gray-100">Top Reviewer</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">Most reviews given</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="fas fa-code-pull-request text-indigo-500"></i>
|
||||
<span class="font-medium text-gray-100">Top PR Author</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">Most PRs opened</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Team Scoring -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-users mr-2 text-blue-500"></i>
|
||||
Team Scoring
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
When teams are configured, Git Velocity calculates team metrics:
|
||||
</p>
|
||||
<ul class="space-y-2 text-gray-400">
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i><strong>Total Team Score:</strong> Sum of all member scores</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i><strong>Average Score:</strong> Total score / number of members</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i><strong>Member Breakdown:</strong> Individual scores and achievements per team member</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Achievements Section -->
|
||||
<section id="achievements" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Achievement System" icon="fas fa-trophy" icon-color="text-yellow-500" />
|
||||
<p class="text-gray-300 mb-8 text-center">115 achievements across 26 categories with tiered progression</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Achievement Categories -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-trophy mr-2 text-yellow-500"></i>
|
||||
Achievement Categories
|
||||
</h3>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Commits -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-code-commit text-primary-500 mr-2"></i>Commits
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 1, 10, 50, 100, 500, 1000</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
First Steps, Getting Started, Contributor, Committed, Code Machine, Code Warrior
|
||||
</div>
|
||||
</div>
|
||||
<!-- PRs Opened -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-code-pull-request text-accent-500 mr-2"></i>PRs Opened
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 1, 10, 25, 50, 100, 250</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
PR Pioneer, PR Regular, PR Pro, Merge Master, PR Champion, PR Legend
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reviews -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-eye text-indigo-500 mr-2"></i>Reviews Given
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 1, 10, 25, 50, 100, 250</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
First Review, Reviewer, Review Regular, Review Expert, Review Guru, Review Master
|
||||
</div>
|
||||
</div>
|
||||
<!-- Review Comments -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-comment text-blue-500 mr-2"></i>Review Comments
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 10, 50, 100, 250, 500</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Commentator, Feedback Giver, Code Critic, Feedback Expert, Comment Champion
|
||||
</div>
|
||||
</div>
|
||||
<!-- Lines Added -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-plus text-green-500 mr-2"></i>Lines Added
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 100, 1K, 5K, 10K, 50K</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
First Hundred, Thousand Lines, Five Thousand, Ten Thousand, Code Mountain
|
||||
</div>
|
||||
</div>
|
||||
<!-- Lines Deleted -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-minus text-red-500 mr-2"></i>Lines Deleted
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 100, 500, 1K, 5K, 10K</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Tidying Up, Spring Cleaning, Code Cleaner, Refactoring Hero, Deletion Master
|
||||
</div>
|
||||
</div>
|
||||
<!-- Response Time -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-bolt text-yellow-500 mr-2"></i>Response Time
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: <24h, <4h, <1h</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Same Day Reviewer, Quick Responder, Speed Demon
|
||||
</div>
|
||||
</div>
|
||||
<!-- Streaks -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-fire text-orange-500 mr-2"></i>Contribution Streaks
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 3, 7, 14, 30 days</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Getting Rolling, Week Warrior, Two Week Streak, Month Master
|
||||
</div>
|
||||
</div>
|
||||
<!-- Activity Patterns -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-clock text-cyan-500 mr-2"></i>Activity Patterns
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Early Bird, Night Owl, Weekend Warrior</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Commits at different times of day unlock special badges
|
||||
</div>
|
||||
</div>
|
||||
<!-- Issues Opened -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-circle-exclamation text-teal-500 mr-2"></i>Issues Opened
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 1, 5, 10, 25, 50</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Issue Opener, Reporter, Bug Hunter, Issue Tracker, Issue Master
|
||||
</div>
|
||||
</div>
|
||||
<!-- Issues Closed -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-circle-check text-green-500 mr-2"></i>Issues Closed
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 1, 5, 10, 25, 50</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Issue Closer, Problem Solver, Resolver, Issue Crusher, Closure King
|
||||
</div>
|
||||
</div>
|
||||
<!-- Issue Comments -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-comment-dots text-blue-500 mr-2"></i>Issue Comments
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 5, 10, 25, 50, 100</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Issue Commenter, Discussion Starter, Feedback Provider, Issue Conversationalist, Discussion Champion
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Achievement Conditions -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-unlock mr-2 text-green-500"></i>
|
||||
How Achievements Are Earned
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Each achievement has a <strong>condition type</strong> and <strong>threshold</strong>.
|
||||
When your metrics meet or exceed the threshold, the achievement is unlocked.
|
||||
</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700">
|
||||
<th class="text-left py-2 text-gray-400">Condition Type</th>
|
||||
<th class="text-left py-2 text-gray-400">Metric Checked</th>
|
||||
<th class="text-left py-2 text-gray-400">Comparison</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-300">
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">commit_count</td>
|
||||
<td class="py-2">Total commits</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">pr_opened_count</td>
|
||||
<td class="py-2">PRs opened</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">review_count</td>
|
||||
<td class="py-2">Reviews given</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">avg_review_time_hours</td>
|
||||
<td class="py-2">Average review response</td>
|
||||
<td class="py-2">≤ threshold (lower is better)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">longest_streak</td>
|
||||
<td class="py-2">Consecutive active days</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">perfect_prs</td>
|
||||
<td class="py-2">PRs with no changes requested</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">issues_opened</td>
|
||||
<td class="py-2">Issues created</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 font-mono text-xs">issues_closed</td>
|
||||
<td class="py-2">Issues resolved/closed</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mt-4">
|
||||
<i class="fas fa-shield-halved mr-1"></i>
|
||||
Achievement definitions are hardcoded and cannot be customized to prevent manipulation.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Tiered Progression -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-layer-group mr-2 text-accent-500"></i>
|
||||
Tiered Progression
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Most achievements have multiple tiers. As you progress, you unlock higher tiers:
|
||||
</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 mb-4">
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-gray-800 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-gray-400 text-white text-xs sm:text-sm font-bold">1</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-gray-300">Tier 1</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-gray-800 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-gray-500 text-white text-xs sm:text-sm font-bold">10</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-gray-300">Tier 2</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-green-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-green-500 text-white text-xs sm:text-sm font-bold">25</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-green-400">Tier 3</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-blue-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-blue-500 text-white text-xs sm:text-sm font-bold">50</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-blue-400">Tier 4</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-purple-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-purple-500 text-white text-xs sm:text-sm font-bold">100</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-purple-400">Tier 5</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-primary-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-primary-500 text-white text-xs sm:text-sm font-bold">250</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-primary-400">Tier 6</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-orange-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-orange-500 text-white text-xs sm:text-sm font-bold">500</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-orange-400">Tier 7</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-yellow-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-gradient-to-r from-yellow-500 to-amber-500 text-white text-xs sm:text-sm font-bold">1k+</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-yellow-400">Tier 8+</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
The leaderboard shows only the highest tier achieved per category for each contributor.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Data Sources Section -->
|
||||
<section id="data-sources" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Data Sources" icon="fab fa-github" icon-color="text-gray-300" />
|
||||
|
||||
<div class="space-y-6">
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fab fa-github mr-2 text-gray-300"></i>
|
||||
GitHub API Data
|
||||
</h3>
|
||||
<div class="grid sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-200 mb-3">Commits</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>SHA, message, timestamp</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Author (login, name, email)</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Additions, deletions, files changed</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Patch/diff for line analysis</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-200 mb-3">Pull Requests</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>State (open, merged, closed)</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Author and timestamps</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Size (additions, deletions)</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Comments count</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-200 mb-3">Reviews</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Review state (approved, changes requested)</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Reviewer login</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Submission timestamp</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Comment count</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-200 mb-3">User Profiles</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>GitHub login (username)</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Display name</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Avatar URL</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Public email (for deduplication)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Calculated Metrics -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-calculator mr-2 text-blue-500"></i>
|
||||
Derived Metrics
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
These metrics are calculated from raw data:
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-2 gap-4 text-sm">
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Meaningful Lines</strong>
|
||||
<p class="text-gray-400">Parsed from commit diffs, filtering comments/whitespace</p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Average Review Time</strong>
|
||||
<p class="text-gray-400">Time between PR creation and first review</p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Contribution Streaks</strong>
|
||||
<p class="text-gray-400">Consecutive days with activity</p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Perfect PRs</strong>
|
||||
<p class="text-gray-400">PRs merged without "changes requested" reviews</p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Out of Hours</strong>
|
||||
<p class="text-gray-400">Commits outside 9am-5pm based on commit timestamp</p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Issue References</strong>
|
||||
<p class="text-gray-400">Commits containing #123 patterns (fixes, closes, resolves, refs)</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Bot Filtering -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-robot mr-2 text-red-500"></i>
|
||||
Bot Filtering
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
By default, bot activity is excluded from metrics. The following patterns are automatically filtered:
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">*[bot]</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">dependabot*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">renovate*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">github-actions*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">codecov*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">snyk*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">greenkeeper*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">imgbot*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">allcontributors*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">semantic-release*</code>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mt-4">
|
||||
<i class="fas fa-cog mr-1"></i>
|
||||
Enable with <code class="text-primary-400">include_bots: true</code> or add custom patterns with <code class="text-primary-400">additional_bot_patterns</code>.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
+153
-58
@@ -1,22 +1,37 @@
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
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 leaderboard = computed(() => globalData.value?.leaderboard || [])
|
||||
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' },
|
||||
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden md:table-cell' },
|
||||
{ key: 'category', label: 'Best At', align: 'left', headerClass: 'hidden sm:table-cell' },
|
||||
{ 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' }
|
||||
]
|
||||
|
||||
@@ -41,64 +56,144 @@ const categoryIcon = (category) => {
|
||||
centered
|
||||
/>
|
||||
|
||||
<!-- Leaderboard Table -->
|
||||
<section class="py-8 px-4">
|
||||
<!-- Search and Leaderboard -->
|
||||
<section class="py-4 sm: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>
|
||||
<!-- 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"
|
||||
@click="searchQuery = ''"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
|
||||
>
|
||||
<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>
|
||||
|
||||
<template #contributor="{ item }">
|
||||
<ContributorRow :contributor="item" show-github-link />
|
||||
</template>
|
||||
<!-- 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" />
|
||||
|
||||
<template #achievements="{ item }">
|
||||
<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>
|
||||
</template>
|
||||
<!-- Avatar -->
|
||||
<Avatar :src="item.avatar_url" :name="item.login" size="md" />
|
||||
|
||||
<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 }}
|
||||
<!-- 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>
|
||||
<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>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,20 @@ const route = useRoute()
|
||||
const repository = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const allContributors = computed(() => repository.value?.contributors || [])
|
||||
|
||||
const filteredContributors = 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 breadcrumbs = computed(() => [
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
@@ -104,26 +118,50 @@ watch(() => route.params, loadRepository)
|
||||
<!-- Contributors -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Contributors" icon="fas fa-users" icon-color="text-blue-500" />
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<SectionHeader title="Contributors" icon="fas fa-users" icon-color="text-blue-500" class="mb-0" />
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative w-full sm:w-72 lg:w-96">
|
||||
<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-4 py-2 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"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="searchQuery = ''"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="searchQuery && filteredContributors.length !== allContributors.length" class="mb-4 text-sm text-gray-400">
|
||||
Showing {{ filteredContributors.length }} of {{ allContributors.length }} contributors
|
||||
</p>
|
||||
|
||||
<DataTable
|
||||
:columns="tableColumns"
|
||||
:items="repository.contributors"
|
||||
:items="filteredContributors"
|
||||
empty-icon="fas fa-users"
|
||||
empty-message="No contributors found"
|
||||
row-class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition group"
|
||||
row-class="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>
|
||||
<span class="text-white">{{ formatNumber(item.commit_count) }}</span>
|
||||
</template>
|
||||
<template #prs="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.prs_opened) }}</span>
|
||||
<span class="text-white">{{ formatNumber(item.prs_opened) }}</span>
|
||||
</template>
|
||||
<template #reviews="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.reviews_given) }}</span>
|
||||
<span class="text-white">{{ formatNumber(item.reviews_given) }}</span>
|
||||
</template>
|
||||
<template #lines="{ item }">
|
||||
<span class="text-green-500">+{{ formatNumber(item.lines_added) }}</span>
|
||||
@@ -131,7 +169,7 @@ watch(() => route.params, loadRepository)
|
||||
<span class="text-red-500">-{{ formatNumber(item.lines_deleted) }}</span>
|
||||
</template>
|
||||
<template #score="{ item }">
|
||||
<span class="text-lg font-bold gradient-text">
|
||||
<span class="text-lg font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ formatNumber(item.score?.total || 0) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user