improvements jan2025 (#9)

* feat(scoring): add tests bonus and fix average calculations

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

* refactor: use standard library and consolidate constants

- [x] Replace custom contains function with slices.Contains
- [x] Remove duplicate contains function implementations
- [x] Extract magic numbers to named constants in formatters
- [x] Create constants composable for app-wide values
- [x] Add ESLint configuration with browser globals
- [x] Add lint npm scripts to package.json
- [x] Reorder Vue template attributes for consistency
- [x] Remove unused variable in AchievementProgress
- [x] Add pnpm lock file
This commit is contained in:
2026-01-13 11:39:35 +00:00
committed by GitHub
parent a23915c620
commit 7ba4d438dd
22 changed files with 2490 additions and 186 deletions
+170 -56
View File
@@ -1,6 +1,7 @@
package aggregator package aggregator
import ( import (
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -72,11 +73,38 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
// Per-repo activity days // Per-repo activity days
repoActivityDays := make(map[string]map[string]map[string]bool) // repo -> login -> set of date strings repoActivityDays := make(map[string]map[string]map[string]bool) // repo -> login -> set of date strings
// Helper to track activity day for a contributor
trackActivityDay := func(login, repo string, date time.Time) {
dateStr := date.Format("2006-01-02")
// Global activity tracking
if activityDays[login] == nil {
activityDays[login] = make(map[string]bool)
}
activityDays[login][dateStr] = true
// Per-repo activity tracking
if repo != "" {
if repoActivityDays[repo] == nil {
repoActivityDays[repo] = make(map[string]map[string]bool)
}
if repoActivityDays[repo][login] == nil {
repoActivityDays[repo][login] = make(map[string]bool)
}
repoActivityDays[repo][login][dateStr] = true
}
}
// Track unique files per contributor for accurate FilesChanged count // Track unique files per contributor for accurate FilesChanged count
contributorFiles := make(map[string]map[string]bool) // login -> set of file paths contributorFiles := make(map[string]map[string]bool) // login -> set of file paths
// Per-repo unique files per contributor // Per-repo unique files per contributor
repoContributorFiles := make(map[string]map[string]map[string]bool) // repo -> login -> set of file paths repoContributorFiles := make(map[string]map[string]map[string]bool) // repo -> login -> set of file paths
// Track counts of items with valid time data (for accurate average calculations)
// These track only PRs/reviews that have valid time data, not total counts
reviewsWithResponseTime := make(map[string]int) // login -> count of reviews with valid ResponseTime
repoReviewsWithResponseTime := make(map[string]map[string]int) // repo -> login -> count
prsWithTimeToMerge := make(map[string]int) // login -> count of PRs with valid TimeToMerge
repoPRsWithTimeToMerge := make(map[string]map[string]int) // repo -> login -> count
// Helper to get or create per-repo contributor // Helper to get or create per-repo contributor
getRepoContributor := func(repo, login, name, avatarURL string) *models.ContributorMetrics { getRepoContributor := func(repo, login, name, avatarURL string) *models.ContributorMetrics {
if repoContributorMap[repo] == nil { if repoContributorMap[repo] == nil {
@@ -140,6 +168,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
cm := contributorMap[login] cm := contributorMap[login]
cm.CommitCount++ cm.CommitCount++
if commit.HasTests {
cm.CommitsWithTests++
}
cm.LinesAdded += commit.Additions cm.LinesAdded += commit.Additions
cm.LinesDeleted += commit.Deletions cm.LinesDeleted += commit.Deletions
cm.MeaningfulLinesAdded += commit.MeaningfulAdditions cm.MeaningfulLinesAdded += commit.MeaningfulAdditions
@@ -157,6 +188,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
// Update per-repo contributor stats // Update per-repo contributor stats
rcm := getRepoContributor(commit.Repository, login, cm.Name, cm.AvatarURL) rcm := getRepoContributor(commit.Repository, login, cm.Name, cm.AvatarURL)
rcm.CommitCount++ rcm.CommitCount++
if commit.HasTests {
rcm.CommitsWithTests++
}
rcm.LinesAdded += commit.Additions rcm.LinesAdded += commit.Additions
rcm.LinesDeleted += commit.Deletions rcm.LinesDeleted += commit.Deletions
rcm.MeaningfulLinesAdded += commit.MeaningfulAdditions rcm.MeaningfulLinesAdded += commit.MeaningfulAdditions
@@ -178,8 +212,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
hour := commit.Date.Hour() hour := commit.Date.Hour()
weekday := commit.Date.Weekday() weekday := commit.Date.Weekday()
// Early bird: commits before 9am (for achievements) // Early bird: commits between 6am-9am (for achievements)
if hour >= 5 && hour < 9 { // Aligned with the early morning multiplier range
if hour >= 6 && hour < 9 {
cm.EarlyBirdCount++ cm.EarlyBirdCount++
rcm.EarlyBirdCount++ rcm.EarlyBirdCount++
} }
@@ -233,24 +268,11 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
rcm.EarlyMorningCount++ rcm.EarlyMorningCount++
} }
// Track activity days (global) // Track activity day for this commit
if activityDays[login] == nil { trackActivityDay(login, commit.Repository, commit.Date)
activityDays[login] = make(map[string]bool)
}
dateStr := commit.Date.Format("2006-01-02")
activityDays[login][dateStr] = true
// Track activity days (per-repo)
if repoActivityDays[commit.Repository] == nil {
repoActivityDays[commit.Repository] = make(map[string]map[string]bool)
}
if repoActivityDays[commit.Repository][login] == nil {
repoActivityDays[commit.Repository][login] = make(map[string]bool)
}
repoActivityDays[commit.Repository][login][dateStr] = true
// Track repository participation // Track repository participation
if !contains(cm.RepositoriesContributed, commit.Repository) { if !slices.Contains(cm.RepositoriesContributed, commit.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, commit.Repository) cm.RepositoriesContributed = append(cm.RepositoriesContributed, commit.Repository)
} }
@@ -307,6 +329,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
rcm := getRepoContributor(pr.Repository, login, cm.Name, cm.AvatarURL) rcm := getRepoContributor(pr.Repository, login, cm.Name, cm.AvatarURL)
rcm.PRsOpened++ rcm.PRsOpened++
// Track activity day for PR creation
trackActivityDay(login, pr.Repository, pr.CreatedAt)
prSize := pr.Additions + pr.Deletions prSize := pr.Additions + pr.Deletions
if pr.IsMerged() { if pr.IsMerged() {
@@ -316,6 +341,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
// Accumulate for average calculation // Accumulate for average calculation
cm.AvgTimeToMerge += pr.TimeToMerge.Hours() cm.AvgTimeToMerge += pr.TimeToMerge.Hours()
rcm.AvgTimeToMerge += pr.TimeToMerge.Hours() rcm.AvgTimeToMerge += pr.TimeToMerge.Hours()
// Track count of PRs with valid time data for accurate average
prsWithTimeToMerge[login]++
if repoPRsWithTimeToMerge[pr.Repository] == nil {
repoPRsWithTimeToMerge[pr.Repository] = make(map[string]int)
}
repoPRsWithTimeToMerge[pr.Repository][login]++
} }
// Track largest PR // Track largest PR
@@ -337,7 +368,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
} }
// Track repository participation // Track repository participation
if !contains(cm.RepositoriesContributed, pr.Repository) { if !slices.Contains(cm.RepositoriesContributed, pr.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, pr.Repository) cm.RepositoriesContributed = append(cm.RepositoriesContributed, pr.Repository)
} }
@@ -372,6 +403,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
rcm.ReviewsGiven++ rcm.ReviewsGiven++
rcm.ReviewComments += review.CommentsCount rcm.ReviewComments += review.CommentsCount
// Track activity day for review submission
trackActivityDay(login, review.Repository, review.SubmittedAt)
if review.IsApproval() { if review.IsApproval() {
cm.ApprovalsGiven++ cm.ApprovalsGiven++
rcm.ApprovalsGiven++ rcm.ApprovalsGiven++
@@ -395,6 +429,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
if review.ResponseTime != nil { if review.ResponseTime != nil {
cm.AvgReviewTime += review.ResponseTime.Hours() cm.AvgReviewTime += review.ResponseTime.Hours()
rcm.AvgReviewTime += review.ResponseTime.Hours() rcm.AvgReviewTime += review.ResponseTime.Hours()
// Track count of reviews with valid time data for accurate average
reviewsWithResponseTime[login]++
if repoReviewsWithResponseTime[review.Repository] == nil {
repoReviewsWithResponseTime[review.Repository] = make(map[string]int)
}
repoReviewsWithResponseTime[review.Repository][login]++
} }
// Track unique reviewees // Track unique reviewees
@@ -452,21 +492,47 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
cm := contributorMap[login] cm := contributorMap[login]
cm.IssuesOpened++ cm.IssuesOpened++
if issue.IsClosed() && issue.ClosedBy != nil && issue.ClosedBy.Login == login { // Track activity day for issue creation
cm.IssuesClosed++ trackActivityDay(login, issue.Repository, issue.CreatedAt)
}
// Track repository participation // Track repository participation
if !contains(cm.RepositoriesContributed, issue.Repository) { if !slices.Contains(cm.RepositoriesContributed, issue.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, issue.Repository) cm.RepositoriesContributed = append(cm.RepositoriesContributed, issue.Repository)
} }
// Update per-repo contributor metrics // Update per-repo contributor metrics
rcm := getRepoContributor(issue.Repository, login, cm.Name, cm.AvatarURL) rcm := getRepoContributor(issue.Repository, login, cm.Name, cm.AvatarURL)
rcm.IssuesOpened++ rcm.IssuesOpened++
if issue.IsClosed() && issue.ClosedBy != nil && issue.ClosedBy.Login == login {
rcm.IssuesClosed++
} }
// Count issues closed by each contributor (separate from who opened them)
// This gives credit to whoever closed the issue, even if they didn't open it
for _, issue := range data.Issues {
if !issue.IsClosed() || issue.ClosedBy == nil || issue.ClosedBy.Login == "" {
continue
}
closerLogin := issue.ClosedBy.Login
// Initialize contributor if needed (someone who closes issues but didn't open any)
if _, ok := contributorMap[closerLogin]; !ok {
contributorMap[closerLogin] = &models.ContributorMetrics{
Login: closerLogin,
Period: period,
}
}
cm := contributorMap[closerLogin]
cm.IssuesClosed++
// Track repository participation for the closer
if !slices.Contains(cm.RepositoriesContributed, issue.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, issue.Repository)
}
// Update per-repo contributor metrics for the closer
rcm := getRepoContributor(issue.Repository, closerLogin, cm.Name, cm.AvatarURL)
rcm.IssuesClosed++
} }
// Process issue comments // Process issue comments
@@ -487,8 +553,11 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
cm := contributorMap[login] cm := contributorMap[login]
cm.IssueComments++ cm.IssueComments++
// Track activity day for issue comment
trackActivityDay(login, comment.Repository, comment.CreatedAt)
// Track repository participation // Track repository participation
if !contains(cm.RepositoriesContributed, comment.Repository) { if !slices.Contains(cm.RepositoriesContributed, comment.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, comment.Repository) cm.RepositoriesContributed = append(cm.RepositoriesContributed, comment.Repository)
} }
@@ -550,20 +619,23 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
// Calculate averages and finalize contributor metrics // Calculate averages and finalize contributor metrics
for login, cm := range contributorMap { for login, cm := range contributorMap {
// Calculate average time to merge // Calculate average time to merge (only from PRs that have TimeToMerge data)
if count := prsWithTimeToMerge[login]; count > 0 {
cm.AvgTimeToMerge = cm.AvgTimeToMerge / float64(count)
}
// Calculate average review time (only from reviews that have ResponseTime data)
if count := reviewsWithResponseTime[login]; count > 0 {
cm.AvgReviewTime = cm.AvgReviewTime / float64(count)
}
// Calculate average PR size (only for merged PRs to exclude abandoned PRs)
if cm.PRsMerged > 0 { if cm.PRsMerged > 0 {
cm.AvgTimeToMerge = cm.AvgTimeToMerge / float64(cm.PRsMerged)
}
// Calculate average review time
if cm.ReviewsGiven > 0 {
cm.AvgReviewTime = cm.AvgReviewTime / float64(cm.ReviewsGiven)
}
// Calculate average PR size
if cm.PRsOpened > 0 {
totalPRLines := 0 totalPRLines := 0
for _, pr := range data.PullRequests { for _, pr := range data.PullRequests {
if !pr.IsMerged() {
continue // Only count merged PRs
}
// Normalize PR author login before comparison // Normalize PR author login before comparison
prLogin := pr.Author.Login prLogin := pr.Author.Login
if normalized, ok := prAuthorToNormalizedLogin[prLogin]; ok { if normalized, ok := prAuthorToNormalizedLogin[prLogin]; ok {
@@ -573,7 +645,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
totalPRLines += pr.TotalChanges() totalPRLines += pr.TotalChanges()
} }
} }
cm.AvgPRSize = float64(totalPRLines) / float64(cm.PRsOpened) cm.AvgPRSize = float64(totalPRLines) / float64(cm.PRsMerged)
} }
// Set unique reviewees count // Set unique reviewees count
@@ -617,17 +689,26 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
// Calculate averages for per-repo contributors // Calculate averages for per-repo contributors
for login, rcm := range repoContribs { for login, rcm := range repoContribs {
if rcm.PRsMerged > 0 { // Use count of PRs with valid time data for accurate average
rcm.AvgTimeToMerge = rcm.AvgTimeToMerge / float64(rcm.PRsMerged) if repoPRCounts, ok := repoPRsWithTimeToMerge[repo]; ok {
if count := repoPRCounts[login]; count > 0 {
rcm.AvgTimeToMerge = rcm.AvgTimeToMerge / float64(count)
}
}
// Use count of reviews with valid time data for accurate average
if repoReviewCounts, ok := repoReviewsWithResponseTime[repo]; ok {
if count := repoReviewCounts[login]; count > 0 {
rcm.AvgReviewTime = rcm.AvgReviewTime / float64(count)
} }
if rcm.ReviewsGiven > 0 {
rcm.AvgReviewTime = rcm.AvgReviewTime / float64(rcm.ReviewsGiven)
} }
// Calculate average PR size for this repo // Calculate average PR size for this repo (only for merged PRs to exclude abandoned PRs)
if rcm.PRsOpened > 0 { if rcm.PRsMerged > 0 {
totalPRLines := 0 totalPRLines := 0
for _, pr := range data.PullRequests { for _, pr := range data.PullRequests {
if !pr.IsMerged() {
continue // Only count merged PRs
}
// Normalize PR author login before comparison // Normalize PR author login before comparison
prLogin := pr.Author.Login prLogin := pr.Author.Login
if mapped, ok := loginToLogin[prLogin]; ok { if mapped, ok := loginToLogin[prLogin]; ok {
@@ -637,7 +718,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
totalPRLines += pr.TotalChanges() totalPRLines += pr.TotalChanges()
} }
} }
rcm.AvgPRSize = float64(totalPRLines) / float64(rcm.PRsOpened) rcm.AvgPRSize = float64(totalPRLines) / float64(rcm.PRsMerged)
} }
// Calculate perfect PRs for this repo // Calculate perfect PRs for this repo
@@ -761,15 +842,6 @@ func parseRepoName(fullName string) (owner, name string) {
return fullName, "" return fullName, ""
} }
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// normalizeForComparison normalizes a string for fuzzy comparison // normalizeForComparison normalizes a string for fuzzy comparison
// by lowercasing and removing spaces, hyphens, underscores, dots, and digits // by lowercasing and removing spaces, hyphens, underscores, dots, and digits
func normalizeForComparison(s string) string { func normalizeForComparison(s string) string {
@@ -1282,7 +1354,47 @@ func buildVelocityTimeline(data *models.RawData, period models.Period, scoringCo
pointsReview = 30 pointsReview = 30
} }
// Aggregate commits by week // Get time-based multipliers with defaults
multRegular := scoringConfig.Points.MultiplierRegularHours
if multRegular == 0 {
multRegular = 1.0
}
multEvening := scoringConfig.Points.MultiplierEvening
if multEvening == 0 {
multEvening = 2.0
}
multLateNight := scoringConfig.Points.MultiplierLateNight
if multLateNight == 0 {
multLateNight = 2.5
}
multOvernight := scoringConfig.Points.MultiplierOvernight
if multOvernight == 0 {
multOvernight = 5.0
}
multEarlyMorning := scoringConfig.Points.MultiplierEarlyMorning
if multEarlyMorning == 0 {
multEarlyMorning = 2.0
}
// Helper to get time-based multiplier for a commit
getTimeMultiplier := func(hour int) float64 {
switch {
case hour >= 9 && hour < 17:
return multRegular // Regular hours: 9am-5pm
case hour >= 17 && hour < 21:
return multEvening // Evening: 5pm-9pm
case hour >= 21 && hour <= 23:
return multLateNight // Late night: 9pm-midnight
case hour >= 0 && hour < 6:
return multOvernight // Overnight: midnight-6am
case hour >= 6 && hour < 9:
return multEarlyMorning // Early morning: 6am-9am
default:
return multRegular
}
}
// Aggregate commits by week (with time-based multipliers)
for _, commit := range data.Commits { for _, commit := range data.Commits {
if commit.Date.Before(start) || commit.Date.After(end) { if commit.Date.Before(start) || commit.Date.After(end) {
continue continue
@@ -1290,7 +1402,9 @@ func buildVelocityTimeline(data *models.RawData, period models.Period, scoringCo
idx := findWeekIndex(commit.Date) idx := findWeekIndex(commit.Date)
if idx >= 0 && idx < len(weeks) { if idx >= 0 && idx < len(weeks) {
weekCommits[idx]++ weekCommits[idx]++
weekScore[idx] += float64(pointsCommit) // Apply time-based multiplier to commit score
multiplier := getTimeMultiplier(commit.Date.Hour())
weekScore[idx] += float64(pointsCommit) * multiplier
} }
} }
-12
View File
@@ -349,18 +349,6 @@ func TestAggregator_MultipleRepositories(t *testing.T) {
assert.Len(t, metrics.Repositories, 2) assert.Len(t, metrics.Repositories, 2)
} }
func TestContains(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
assert.True(t, contains(slice, "a"))
assert.True(t, contains(slice, "b"))
assert.True(t, contains(slice, "c"))
assert.False(t, contains(slice, "d"))
assert.False(t, contains([]string{}, "a"))
}
func TestParseRepoName(t *testing.T) { func TestParseRepoName(t *testing.T) {
t.Parallel() t.Parallel()
+2
View File
@@ -19,6 +19,7 @@ type ContributorMetrics struct {
// Commit metrics // Commit metrics
CommitCount int `json:"commit_count"` CommitCount int `json:"commit_count"`
CommitsWithTests int `json:"commits_with_tests"` // Commits that include test files
LinesAdded int `json:"lines_added"` LinesAdded int `json:"lines_added"`
LinesDeleted int `json:"lines_deleted"` LinesDeleted int `json:"lines_deleted"`
FilesChanged int `json:"files_changed"` FilesChanged int `json:"files_changed"`
@@ -98,6 +99,7 @@ type ScoreBreakdown struct {
Issues int `json:"issues"` // Issue-related points (opened, closed, comments, references) Issues int `json:"issues"` // Issue-related points (opened, closed, comments, references)
ResponseBonus int `json:"response_bonus"` ResponseBonus int `json:"response_bonus"`
LineChanges int `json:"line_changes"` LineChanges int `json:"line_changes"`
TestsBonus int `json:"tests_bonus"` // Bonus for commits that include test files
OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits
} }
+28 -12
View File
@@ -1,6 +1,7 @@
package scoring package scoring
import ( import (
"slices"
"sort" "sort"
"github.com/lukaszraczylo/git-velocity/internal/config" "github.com/lukaszraczylo/git-velocity/internal/config"
@@ -23,9 +24,28 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
return metrics return metrics
} }
// Collect all contributor metrics across repositories // Build contributor map for scoring
// IMPORTANT: Prefer metrics.Contributors if populated (from aggregator) since it contains
// properly calculated values that can't be reconstructed from per-repo data:
// - Weighted average times (AvgReviewTime, AvgTimeToMerge)
// - Cross-repo streaks (ActiveDays, LongestStreak, WorkWeekStreak)
// - Max values (LargestPRSize)
// - Deduplicated counts (UniqueReviewees, FilesChanged)
// - Summed counts (SmallPRCount, PerfectPRs)
// Fall back to aggregating from repos only for tests that don't use the full pipeline.
contributorMap := make(map[string]*models.ContributorMetrics) contributorMap := make(map[string]*models.ContributorMetrics)
if len(metrics.Contributors) > 0 {
// Use already-aggregated global contributors (production path)
for i := range metrics.Contributors {
login := metrics.Contributors[i].Login
cm := metrics.Contributors[i]
contributorMap[login] = &cm
}
} else {
// Fallback: aggregate from per-repo contributors (test compatibility path)
// Note: This path cannot properly aggregate computed fields like AvgReviewTime,
// LongestStreak, etc. - it only sums count-based metrics.
for _, repo := range metrics.Repositories { for _, repo := range metrics.Repositories {
for i := range repo.Contributors { for i := range repo.Contributors {
login := repo.Contributors[i].Login login := repo.Contributors[i].Login
@@ -38,6 +58,7 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
existing := contributorMap[login] existing := contributorMap[login]
cm := repo.Contributors[i] cm := repo.Contributors[i]
existing.CommitCount += cm.CommitCount existing.CommitCount += cm.CommitCount
existing.CommitsWithTests += cm.CommitsWithTests
existing.LinesAdded += cm.LinesAdded existing.LinesAdded += cm.LinesAdded
existing.LinesDeleted += cm.LinesDeleted existing.LinesDeleted += cm.LinesDeleted
existing.MeaningfulLinesAdded += cm.MeaningfulLinesAdded existing.MeaningfulLinesAdded += cm.MeaningfulLinesAdded
@@ -67,13 +88,14 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
existing.EarlyMorningCount += cm.EarlyMorningCount existing.EarlyMorningCount += cm.EarlyMorningCount
// Combine unique repositories // Combine unique repositories
for _, r := range cm.RepositoriesContributed { for _, r := range cm.RepositoriesContributed {
if !contains(existing.RepositoriesContributed, r) { if !slices.Contains(existing.RepositoriesContributed, r) {
existing.RepositoriesContributed = append(existing.RepositoriesContributed, r) existing.RepositoriesContributed = append(existing.RepositoriesContributed, r)
} }
} }
} }
} }
} }
}
// Calculate scores for each contributor // Calculate scores for each contributor
for _, cm := range contributorMap { for _, cm := range contributorMap {
@@ -260,13 +282,16 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
} }
} }
// Tests bonus - bonus points for commits that include test files
breakdown.TestsBonus = cm.CommitsWithTests * points.CommitWithTests
// Out of hours bonus (legacy - kept for backwards compatibility but default is 0) // Out of hours bonus (legacy - kept for backwards compatibility but default is 0)
breakdown.OutOfHours = cm.OutOfHoursCount * points.OutOfHours breakdown.OutOfHours = cm.OutOfHoursCount * points.OutOfHours
// Calculate total // Calculate total
total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs + total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs +
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments + breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments +
breakdown.Issues + breakdown.OutOfHours breakdown.Issues + breakdown.TestsBonus + breakdown.OutOfHours
return models.Score{ return models.Score{
Total: total, Total: total,
@@ -406,12 +431,3 @@ func (c *Calculator) findTopAchievers(contributors []models.ContributorMetrics,
topAchievers["pull_requests"] = topPRAuthor topAchievers["pull_requests"] = topPRAuthor
} }
} }
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
@@ -843,18 +843,6 @@ func TestCalculator_WorkWeekStreakAchievement(t *testing.T) {
assert.Contains(t, contributor.Achievements, "workweek-5") assert.Contains(t, contributor.Achievements, "workweek-5")
} }
func TestContains(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
assert.True(t, contains(slice, "a"))
assert.True(t, contains(slice, "b"))
assert.True(t, contains(slice, "c"))
assert.False(t, contains(slice, "d"))
assert.False(t, contains([]string{}, "a"))
}
func TestCalculator_MeaningfulLinesScoring(t *testing.T) { func TestCalculator_MeaningfulLinesScoring(t *testing.T) {
t.Parallel() t.Parallel()
+15 -9
View File
@@ -221,7 +221,7 @@ type gqlPRQuery struct {
TotalCount int TotalCount int
PageInfo PageInfo PageInfo PageInfo
Nodes []gqlPRNode Nodes []gqlPRNode
} `graphql:"pullRequests(first: 100, after: $cursor, states: [MERGED], orderBy: {field: UPDATED_AT, direction: DESC})"` } `graphql:"pullRequests(first: 100, after: $cursor, states: [OPEN, MERGED, CLOSED], orderBy: {field: UPDATED_AT, direction: DESC})"`
} `graphql:"repository(owner: $owner, name: $repo)"` } `graphql:"repository(owner: $owner, name: $repo)"`
} }
@@ -329,23 +329,29 @@ func (g *GraphQLClient) FetchPRsWithReviews(ctx context.Context, owner, repo str
} }
}, },
ProcessNode: func(node gqlPRNode, repoName string) ([]prWithReviews, bool, bool) { ProcessNode: func(node gqlPRNode, repoName string) ([]prWithReviews, bool, bool) {
// Skip if not merged - not counted as "old" // Determine the relevant date for filtering:
if node.MergedAt == nil { // - For merged PRs: use MergedAt
return nil, false, false // - For closed PRs: use ClosedAt
// - For open PRs: use CreatedAt (they're still active)
var relevantDate time.Time
if node.MergedAt != nil {
relevantDate = *node.MergedAt
} else if node.ClosedAt != nil {
relevantDate = *node.ClosedAt
} else {
relevantDate = node.CreatedAt
} }
mergedAt := *node.MergedAt
// Hard cutoff check - stop entirely if past this date // Hard cutoff check - stop entirely if past this date
if hardCutoff != nil && mergedAt.Before(*hardCutoff) { if hardCutoff != nil && relevantDate.Before(*hardCutoff) {
return nil, true, true // Hard stop return nil, true, true // Hard stop
} }
// Check date range - skip if outside range // Check date range - skip if outside range
if until != nil && mergedAt.After(*until) { if until != nil && relevantDate.After(*until) {
return nil, false, false // Too new, not "old" return nil, false, false // Too new, not "old"
} }
if since != nil && mergedAt.Before(*since) { if since != nil && relevantDate.Before(*since) {
return nil, true, false // Too old - signal for early termination tracking return nil, true, false // Too old - signal for early termination tracking
} }
+42
View File
@@ -0,0 +1,42 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
export default [
js.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
languageOptions: {
globals: {
// Browser globals
window: 'readonly',
document: 'readonly',
fetch: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly'
}
},
rules: {
// Vue specific rules
'vue/multi-word-component-names': 'off', // Allow single-word component names
'vue/max-attributes-per-line': 'off', // Allow multiple attributes per line
'vue/singleline-html-element-content-newline': 'off',
'vue/html-self-closing': ['error', {
html: { void: 'always', normal: 'never', component: 'always' }
}],
// General JS rules
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': 'warn',
'prefer-const': 'error',
'no-var': 'error'
}
},
{
ignores: ['dist/**', 'node_modules/**']
}
]
+6 -1
View File
@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "eslint src",
"lint:fix": "eslint src --fix"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
@@ -16,9 +18,12 @@
"vue-router": "^4.2.5" "vue-router": "^4.2.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.6.2",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"vite": "^7.2.7" "vite": "^7.2.7"
+2100
View File
File diff suppressed because it is too large Load Diff
@@ -234,7 +234,6 @@ const progressItems = computed(() => {
// Get count of remaining achievements (all unearned across all types) // Get count of remaining achievements (all unearned across all types)
const remainingCount = computed(() => { const remainingCount = computed(() => {
const earnedSet = new Set(props.contributor.achievements || [])
let totalUnearned = 0 let totalUnearned = 0
for (const type of achievementTypes) { for (const type of achievementTypes) {
+1 -1
View File
@@ -13,6 +13,6 @@ defineProps({
hover ? 'hover:shadow-lg transition-shadow' : '' hover ? 'hover:shadow-lg transition-shadow' : ''
]" ]"
> >
<slot /> <slot></slot>
</div> </div>
</template> </template>
-1
View File
@@ -1,7 +1,6 @@
<script setup> <script setup>
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import Avatar from './Avatar.vue' import Avatar from './Avatar.vue'
import { formatNumber } from '../composables/formatters'
defineProps({ defineProps({
contributor: { contributor: {
+5 -5
View File
@@ -51,8 +51,8 @@ const repositories = computed(() => globalData.value?.Repositories || [])
<!-- Mobile Menu Button --> <!-- Mobile Menu Button -->
<button <button
@click="mobileMenuOpen = !mobileMenuOpen"
class="md:hidden p-2 rounded-lg hover:bg-gray-700 transition" class="md:hidden p-2 rounded-lg hover:bg-gray-700 transition"
@click="mobileMenuOpen = !mobileMenuOpen"
> >
<i class="fas fa-bars text-gray-200"></i> <i class="fas fa-bars text-gray-200"></i>
</button> </button>
@@ -63,37 +63,37 @@ const repositories = computed(() => globalData.value?.Repositories || [])
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
<RouterLink <RouterLink
to="/" to="/"
@click="mobileMenuOpen = false"
:class="[ :class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors', 'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path === '/' route.path === '/'
? 'bg-primary-900/20 text-primary-400' ? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800' : 'text-gray-200 hover:bg-gray-800'
]" ]"
@click="mobileMenuOpen = false"
> >
<i class="fas fa-home mr-3 w-5 text-center"></i>Dashboard <i class="fas fa-home mr-3 w-5 text-center"></i>Dashboard
</RouterLink> </RouterLink>
<RouterLink <RouterLink
to="/leaderboard" to="/leaderboard"
@click="mobileMenuOpen = false"
:class="[ :class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors', 'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path === '/leaderboard' route.path === '/leaderboard'
? 'bg-primary-900/20 text-primary-400' ? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800' : 'text-gray-200 hover:bg-gray-800'
]" ]"
@click="mobileMenuOpen = false"
> >
<i class="fas fa-trophy mr-3 w-5 text-center"></i>Leaderboard <i class="fas fa-trophy mr-3 w-5 text-center"></i>Leaderboard
</RouterLink> </RouterLink>
<RouterLink <RouterLink
to="/how-scoring-works" to="/how-scoring-works"
@click="mobileMenuOpen = false"
:class="[ :class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors', 'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path === '/how-scoring-works' route.path === '/how-scoring-works'
? 'bg-primary-900/20 text-primary-400' ? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800' : 'text-gray-200 hover:bg-gray-800'
]" ]"
@click="mobileMenuOpen = false"
> >
<i class="fas fa-calculator mr-3 w-5 text-center"></i>How Scoring Works <i class="fas fa-calculator mr-3 w-5 text-center"></i>How Scoring Works
</RouterLink> </RouterLink>
@@ -101,13 +101,13 @@ const repositories = computed(() => globalData.value?.Repositories || [])
v-for="repo in repositories" v-for="repo in repositories"
:key="`${repo.Owner}/${repo.Name}`" :key="`${repo.Owner}/${repo.Name}`"
:to="`/repos/${repo.Owner}/${repo.Name}`" :to="`/repos/${repo.Owner}/${repo.Name}`"
@click="mobileMenuOpen = false"
:class="[ :class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors', 'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) route.path.includes(`/repos/${repo.Owner}/${repo.Name}`)
? 'bg-primary-900/20 text-primary-400' ? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800' : 'text-gray-200 hover:bg-gray-800'
]" ]"
@click="mobileMenuOpen = false"
> >
<i class="fas fa-code-branch mr-3 w-5 text-center"></i>{{ repo.Name }} <i class="fas fa-code-branch mr-3 w-5 text-center"></i>{{ repo.Name }}
</RouterLink> </RouterLink>
+2 -1
View File
@@ -3,6 +3,7 @@ import { RouterLink } from 'vue-router'
import Card from './Card.vue' import Card from './Card.vue'
import Avatar from './Avatar.vue' import Avatar from './Avatar.vue'
import { formatNumber, slugify } from '../composables/formatters' import { formatNumber, slugify } from '../composables/formatters'
import { DEFAULT_TEAM_COLOR } from '../composables/constants'
defineProps({ defineProps({
team: { team: {
@@ -24,7 +25,7 @@ defineProps({
</h3> </h3>
<span <span
class="w-3 h-3 rounded-full" class="w-3 h-3 rounded-full"
:style="{ backgroundColor: team.color || '#8b5cf6' }" :style="{ backgroundColor: team.color || DEFAULT_TEAM_COLOR }"
></span> ></span>
</div> </div>
+12
View File
@@ -0,0 +1,12 @@
/**
* Application constants
*/
// Default colors
export const DEFAULT_TEAM_COLOR = '#8b5cf6' // Purple - matches accent color palette
// Data paths
export const DATA_BASE_PATH = './data'
export const GLOBAL_DATA_PATH = `${DATA_BASE_PATH}/global.json`
export const CONTRIBUTORS_PATH = `${DATA_BASE_PATH}/contributors`
export const REPOS_PATH = `${DATA_BASE_PATH}/repos`
+16 -8
View File
@@ -1,13 +1,21 @@
// Number formatting thresholds
const ONE_MILLION = 1_000_000
const ONE_THOUSAND = 1_000
// Time conversion constants
const MINUTES_PER_HOUR = 60
const HOURS_PER_DAY = 24
/** /**
* Format a number with K/M suffixes for large values * Format a number with K/M suffixes for large values
*/ */
export function formatNumber(n) { export function formatNumber(n) {
if (n === null || n === undefined) return '0' if (n === null || n === undefined) return '0'
if (n >= 1000000) { if (n >= ONE_MILLION) {
return (n / 1000000).toFixed(1) + 'M' return (n / ONE_MILLION).toFixed(1) + 'M'
} }
if (n >= 1000) { if (n >= ONE_THOUSAND) {
return (n / 1000).toFixed(1) + 'K' return (n / ONE_THOUSAND).toFixed(1) + 'K'
} }
return String(n) return String(n)
} }
@@ -16,14 +24,14 @@ export function formatNumber(n) {
* Format hours as a human-readable duration * Format hours as a human-readable duration
*/ */
export function formatDuration(hours) { export function formatDuration(hours) {
if (hours === null || hours === undefined) return '-' if (hours === null || hours === undefined || hours <= 0) return '-'
if (hours < 1) { if (hours < 1) {
return Math.round(hours * 60) + 'm' return Math.round(hours * MINUTES_PER_HOUR) + 'm'
} }
if (hours < 24) { if (hours < HOURS_PER_DAY) {
return hours.toFixed(1) + 'h' return hours.toFixed(1) + 'h'
} }
return (hours / 24).toFixed(1) + 'd' return (hours / HOURS_PER_DAY).toFixed(1) + 'd'
} }
/** /**
+16 -2
View File
@@ -79,8 +79,22 @@ async function loadContributor() {
} }
onMounted(loadContributor) onMounted(loadContributor)
watch(() => route.params, loadContributor)
watch(globalData, loadContributor) // Watch for route changes (navigation to different contributor)
watch(() => route.params.login, (newLogin, oldLogin) => {
if (newLogin && newLogin !== oldLogin) {
loadContributor()
}
})
// Watch for globalData changes, but only reload if we don't have contributor data yet
// This prevents double-loading when both route and globalData change on initial navigation
watch(globalData, (newData, oldData) => {
// Only reload if globalData became available and we have an error or no data
if (newData && !oldData && (error.value || !contributor.value)) {
loadContributor()
}
})
</script> </script>
<template> <template>
+1 -1
View File
@@ -57,8 +57,8 @@ const showScoreInChart = ref(false)
<SectionHeader title="Velocity Timeline" icon="fas fa-chart-line" icon-color="text-primary-500" /> <SectionHeader title="Velocity Timeline" icon="fas fa-chart-line" icon-color="text-primary-500" />
<label class="flex items-center space-x-2 text-sm text-gray-400 cursor-pointer"> <label class="flex items-center space-x-2 text-sm text-gray-400 cursor-pointer">
<input <input
type="checkbox"
v-model="showScoreInChart" v-model="showScoreInChart"
type="checkbox"
class="rounded border-gray-600 text-primary-500 focus:ring-primary-500" class="rounded border-gray-600 text-primary-500 focus:ring-primary-500"
/> />
<span>Show Score</span> <span>Show Score</span>
+2 -1
View File
@@ -64,7 +64,7 @@ import SectionHeader from '../components/SectionHeader.vue'
Score Formula Score Formula
</h3> </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"> <div class="bg-gray-900 text-gray-100 p-3 sm:p-4 rounded-lg overflow-x-auto mb-4 -mx-2 sm:mx-0">
<pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Response <pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Tests + Response
Where: Where:
Commits = sum of (commits x 10 x time_multiplier) Commits = sum of (commits x 10 x time_multiplier)
@@ -73,6 +73,7 @@ Where:
Reviews = reviews_given x 30 pts Reviews = reviews_given x 30 pts
Comments = review_comments x 5 pts Comments = review_comments x 5 pts
Issues = (opened x 10) + (closed x 20) + (comments x 5) + (refs x 5) pts Issues = (opened x 10) + (closed x 20) + (comments x 5) + (refs x 5) pts
Tests = commits_with_tests x 15 pts
Response = fast review bonus (0-50 pts) Response = fast review bonus (0-50 pts)
Time Multipliers: Time Multipliers:
+1 -11
View File
@@ -34,16 +34,6 @@ const tableColumns = [
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden xl:table-cell' }, { key: 'team', label: 'Team', align: 'left', headerClass: 'hidden xl:table-cell' },
{ key: 'score', label: 'Score', align: 'right' } { key: 'score', label: 'Score', align: 'right' }
] ]
const categoryIcon = (category) => {
const icons = {
'Commits': 'fas fa-code-commit text-green-500',
'PRs': 'fas fa-code-pull-request text-blue-500',
'Reviews': 'fas fa-eye text-purple-500',
'Comments': 'fas fa-comment text-orange-500'
}
return icons[category] || ''
}
</script> </script>
<template> <template>
@@ -71,8 +61,8 @@ const categoryIcon = (category) => {
/> />
<button <button
v-if="searchQuery" v-if="searchQuery"
@click="searchQuery = ''"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
@click="searchQuery = ''"
> >
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
+8 -2
View File
@@ -61,7 +61,13 @@ async function loadRepository() {
} }
onMounted(loadRepository) onMounted(loadRepository)
watch(() => route.params, loadRepository)
// Watch for route changes (navigation to different repository)
watch(() => [route.params.owner, route.params.name], ([newOwner, newName], [oldOwner, oldName]) => {
if ((newOwner && newName) && (newOwner !== oldOwner || newName !== oldName)) {
loadRepository()
}
})
</script> </script>
<template> <template>
@@ -132,8 +138,8 @@ watch(() => route.params, loadRepository)
/> />
<button <button
v-if="searchQuery" v-if="searchQuery"
@click="searchQuery = ''"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
@click="searchQuery = ''"
> >
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
+16 -3
View File
@@ -8,6 +8,7 @@ import StatCard from '../components/StatCard.vue'
import MemberCard from '../components/MemberCard.vue' import MemberCard from '../components/MemberCard.vue'
import SectionHeader from '../components/SectionHeader.vue' import SectionHeader from '../components/SectionHeader.vue'
import { slugify } from '../composables/formatters' import { slugify } from '../composables/formatters'
import { DEFAULT_TEAM_COLOR } from '../composables/constants'
const route = useRoute() const route = useRoute()
const globalData = inject('globalData') const globalData = inject('globalData')
@@ -38,8 +39,20 @@ function loadTeam() {
} }
onMounted(loadTeam) onMounted(loadTeam)
watch(() => route.params, loadTeam)
watch(globalData, loadTeam) // Watch for route changes (navigation to different team)
watch(() => route.params.slug, (newSlug, oldSlug) => {
if (newSlug && newSlug !== oldSlug) {
loadTeam()
}
})
// Watch for globalData changes, but only reload if we don't have team data yet
watch(globalData, (newData, oldData) => {
if (newData && !oldData && (error.value || !team.value)) {
loadTeam()
}
})
</script> </script>
<template> <template>
@@ -56,7 +69,7 @@ watch(globalData, loadTeam)
<template #prefix> <template #prefix>
<div <div
class="w-4 h-4 rounded-full mr-4" class="w-4 h-4 rounded-full mr-4"
:style="{ backgroundColor: team.color || '#8b5cf6' }" :style="{ backgroundColor: team.color || DEFAULT_TEAM_COLOR }"
></div> ></div>
</template> </template>
</PageHeader> </PageHeader>