mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-18 03:43:56 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2bd053906 | |||
| 7ba4d438dd | |||
| a23915c620 | |||
| 8d79d058ec | |||
| 186c856d59 |
@@ -24,10 +24,10 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
github.com/charmbracelet/x/ansi v0.11.4 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 // indirect
|
github.com/clipperhouse/displaywidth v0.7.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.2 // indirect
|
github.com/cloudflare/circl v1.6.2 // indirect
|
||||||
@@ -61,9 +61,9 @@ require (
|
|||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/crypto v0.46.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG
|
|||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
|
github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||||
@@ -141,13 +141,13 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -161,11 +161,11 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if rcm.ReviewsGiven > 0 {
|
// Use count of reviews with valid time data for accurate average
|
||||||
rcm.AvgReviewTime = rcm.AvgReviewTime / float64(rcm.ReviewsGiven)
|
if repoReviewCounts, ok := repoReviewsWithResponseTime[repo]; ok {
|
||||||
|
if count := repoReviewCounts[login]; count > 0 {
|
||||||
|
rcm.AvgReviewTime = rcm.AvgReviewTime / float64(count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ type ContributorMetrics struct {
|
|||||||
Period Period `json:"period"`
|
Period Period `json:"period"`
|
||||||
|
|
||||||
// Commit metrics
|
// Commit metrics
|
||||||
CommitCount int `json:"commit_count"`
|
CommitCount int `json:"commit_count"`
|
||||||
LinesAdded int `json:"lines_added"`
|
CommitsWithTests int `json:"commits_with_tests"` // Commits that include test files
|
||||||
LinesDeleted int `json:"lines_deleted"`
|
LinesAdded int `json:"lines_added"`
|
||||||
FilesChanged int `json:"files_changed"`
|
LinesDeleted int `json:"lines_deleted"`
|
||||||
|
FilesChanged int `json:"files_changed"`
|
||||||
|
|
||||||
// Meaningful line counts (excludes comments and whitespace)
|
// Meaningful line counts (excludes comments and whitespace)
|
||||||
MeaningfulLinesAdded int `json:"meaningful_lines_added"`
|
MeaningfulLinesAdded int `json:"meaningful_lines_added"`
|
||||||
@@ -98,7 +99,8 @@ 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"`
|
||||||
OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits
|
TestsBonus int `json:"tests_bonus"` // Bonus for commits that include test files
|
||||||
|
OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepositoryMetrics holds aggregated metrics for a single repository
|
// RepositoryMetrics holds aggregated metrics for a single repository
|
||||||
|
|||||||
@@ -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,52 +24,73 @@ 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)
|
||||||
|
|
||||||
for _, repo := range metrics.Repositories {
|
if len(metrics.Contributors) > 0 {
|
||||||
for i := range repo.Contributors {
|
// Use already-aggregated global contributors (production path)
|
||||||
login := repo.Contributors[i].Login
|
for i := range metrics.Contributors {
|
||||||
if _, ok := contributorMap[login]; !ok {
|
login := metrics.Contributors[i].Login
|
||||||
// Copy the contributor metrics
|
cm := metrics.Contributors[i]
|
||||||
cm := repo.Contributors[i]
|
contributorMap[login] = &cm
|
||||||
contributorMap[login] = &cm
|
}
|
||||||
} else {
|
} else {
|
||||||
// Aggregate metrics from multiple repos
|
// Fallback: aggregate from per-repo contributors (test compatibility path)
|
||||||
existing := contributorMap[login]
|
// Note: This path cannot properly aggregate computed fields like AvgReviewTime,
|
||||||
cm := repo.Contributors[i]
|
// LongestStreak, etc. - it only sums count-based metrics.
|
||||||
existing.CommitCount += cm.CommitCount
|
for _, repo := range metrics.Repositories {
|
||||||
existing.LinesAdded += cm.LinesAdded
|
for i := range repo.Contributors {
|
||||||
existing.LinesDeleted += cm.LinesDeleted
|
login := repo.Contributors[i].Login
|
||||||
existing.MeaningfulLinesAdded += cm.MeaningfulLinesAdded
|
if _, ok := contributorMap[login]; !ok {
|
||||||
existing.MeaningfulLinesDeleted += cm.MeaningfulLinesDeleted
|
// Copy the contributor metrics
|
||||||
existing.CommentLinesAdded += cm.CommentLinesAdded
|
cm := repo.Contributors[i]
|
||||||
existing.CommentLinesDeleted += cm.CommentLinesDeleted
|
contributorMap[login] = &cm
|
||||||
existing.PRsOpened += cm.PRsOpened
|
} else {
|
||||||
existing.PRsMerged += cm.PRsMerged
|
// Aggregate metrics from multiple repos
|
||||||
existing.ReviewsGiven += cm.ReviewsGiven
|
existing := contributorMap[login]
|
||||||
existing.ReviewComments += cm.ReviewComments
|
cm := repo.Contributors[i]
|
||||||
// Issue metrics
|
existing.CommitCount += cm.CommitCount
|
||||||
existing.IssuesOpened += cm.IssuesOpened
|
existing.CommitsWithTests += cm.CommitsWithTests
|
||||||
existing.IssuesClosed += cm.IssuesClosed
|
existing.LinesAdded += cm.LinesAdded
|
||||||
existing.IssueComments += cm.IssueComments
|
existing.LinesDeleted += cm.LinesDeleted
|
||||||
existing.IssueReferencesInCommits += cm.IssueReferencesInCommits
|
existing.MeaningfulLinesAdded += cm.MeaningfulLinesAdded
|
||||||
// Activity pattern metrics (for achievements)
|
existing.MeaningfulLinesDeleted += cm.MeaningfulLinesDeleted
|
||||||
existing.EarlyBirdCount += cm.EarlyBirdCount
|
existing.CommentLinesAdded += cm.CommentLinesAdded
|
||||||
existing.NightOwlCount += cm.NightOwlCount
|
existing.CommentLinesDeleted += cm.CommentLinesDeleted
|
||||||
existing.MidnightCount += cm.MidnightCount
|
existing.PRsOpened += cm.PRsOpened
|
||||||
existing.WeekendWarrior += cm.WeekendWarrior
|
existing.PRsMerged += cm.PRsMerged
|
||||||
existing.OutOfHoursCount += cm.OutOfHoursCount
|
existing.ReviewsGiven += cm.ReviewsGiven
|
||||||
// Time-based commit counts (for multiplier scoring)
|
existing.ReviewComments += cm.ReviewComments
|
||||||
existing.RegularHoursCount += cm.RegularHoursCount
|
// Issue metrics
|
||||||
existing.EveningCount += cm.EveningCount
|
existing.IssuesOpened += cm.IssuesOpened
|
||||||
existing.LateNightCount += cm.LateNightCount
|
existing.IssuesClosed += cm.IssuesClosed
|
||||||
existing.OvernightCount += cm.OvernightCount
|
existing.IssueComments += cm.IssueComments
|
||||||
existing.EarlyMorningCount += cm.EarlyMorningCount
|
existing.IssueReferencesInCommits += cm.IssueReferencesInCommits
|
||||||
// Combine unique repositories
|
// Activity pattern metrics (for achievements)
|
||||||
for _, r := range cm.RepositoriesContributed {
|
existing.EarlyBirdCount += cm.EarlyBirdCount
|
||||||
if !contains(existing.RepositoriesContributed, r) {
|
existing.NightOwlCount += cm.NightOwlCount
|
||||||
existing.RepositoriesContributed = append(existing.RepositoriesContributed, r)
|
existing.MidnightCount += cm.MidnightCount
|
||||||
|
existing.WeekendWarrior += cm.WeekendWarrior
|
||||||
|
existing.OutOfHoursCount += cm.OutOfHoursCount
|
||||||
|
// Time-based commit counts (for multiplier scoring)
|
||||||
|
existing.RegularHoursCount += cm.RegularHoursCount
|
||||||
|
existing.EveningCount += cm.EveningCount
|
||||||
|
existing.LateNightCount += cm.LateNightCount
|
||||||
|
existing.OvernightCount += cm.OvernightCount
|
||||||
|
existing.EarlyMorningCount += cm.EarlyMorningCount
|
||||||
|
// Combine unique repositories
|
||||||
|
for _, r := range cm.RepositoriesContributed {
|
||||||
|
if !slices.Contains(existing.RepositoriesContributed, r) {
|
||||||
|
existing.RepositoriesContributed = append(existing.RepositoriesContributed, r)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Generated
+2100
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) {
|
||||||
|
|||||||
@@ -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,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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Application constants
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Default colors
|
||||||
|
export const DEFAULT_TEAM_COLOR = '#8b5cf6' // Purple - matches accent color palette
|
||||||
|
|
||||||
|
// Data paths
|
||||||
|
export const DATA_BASE_PATH = './data'
|
||||||
|
export const GLOBAL_DATA_PATH = `${DATA_BASE_PATH}/global.json`
|
||||||
|
export const CONTRIBUTORS_PATH = `${DATA_BASE_PATH}/contributors`
|
||||||
|
export const REPOS_PATH = `${DATA_BASE_PATH}/repos`
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
|
// Number formatting thresholds
|
||||||
|
const ONE_MILLION = 1_000_000
|
||||||
|
const ONE_THOUSAND = 1_000
|
||||||
|
|
||||||
|
// Time conversion constants
|
||||||
|
const MINUTES_PER_HOUR = 60
|
||||||
|
const HOURS_PER_DAY = 24
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a number with K/M suffixes for large values
|
* 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'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user