Compare commits

..

16 Commits

Author SHA1 Message Date
lukaszraczylo 5ca802cae7 Update go.mod and go.sum (#15) 2026-02-02 03:53:23 +00:00
lukaszraczylo 4dc9dc3d07 Update go.mod and go.sum (#14) 2026-01-26 03:40:24 +00:00
lukaszraczylo 7fc413ba92 Update go.mod and go.sum (#13) 2026-01-25 03:38:04 +00:00
lukaszraczylo 821891a890 Update go.mod and go.sum (#12) 2026-01-23 03:35:05 +00:00
lukaszraczylo 063b5acfbb Update go.mod and go.sum (#11) 2026-01-19 03:36:30 +00:00
lukaszraczylo e2bd053906 Update go.mod and go.sum (#10) 2026-01-16 03:33:55 +00:00
lukaszraczylo 7ba4d438dd 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
2026-01-13 11:39:35 +00:00
lukaszraczylo a23915c620 Update go.mod and go.sum (#8) 2026-01-13 03:33:35 +00:00
lukaszraczylo 8d79d058ec Update go.mod and go.sum (#7) 2026-01-12 03:36:11 +00:00
lukaszraczylo 186c856d59 Update go.mod and go.sum (#6) 2026-01-10 03:32:24 +00:00
lukaszraczylo b2c6e991d8 Update go.mod and go.sum (#5) 2026-01-09 03:34:15 +00:00
lukaszraczylo e4ec2470d3 Update go.mod and go.sum (#4) 2025-12-31 03:33:45 +00:00
lukaszraczylo 3854f224b0 Update go.mod and go.sum (#3) 2025-12-23 03:24:44 +00:00
lukaszraczylo 7008f41aff Update secrets configuration in autoupdate workflow 2025-12-23 03:01:18 +00:00
lukaszraczylo ac04848654 Update the views. 2025-12-19 11:22:42 +00:00
lukaszraczylo 3bd9807e50 Fixes calculations (#2)
Git Level (per commit):
    - Track unique file paths in FilesModified slice
    - FilesChanged = count of unique files in THIS commit

  Aggregator Level (per contributor):
    - Collect all file paths from all commits into a SET
    - FilesChanged = size of the unique file set

  Result:
    - Contributor.FilesChanged = count of UNIQUE files they touched
    - Repository contributor = unique files in THAT repo only
2025-12-19 10:44:00 +00:00
31 changed files with 2943 additions and 278 deletions
+1 -2
View File
@@ -16,5 +16,4 @@ jobs:
with:
go-version: "1.24"
release-workflow: "release.yml"
secrets:
pat-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
secrets: inherit
+9 -9
View File
@@ -24,13 +24,13 @@ require (
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // 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/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.6.1 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
@@ -40,7 +40,7 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-github/v75 v75.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
@@ -61,9 +61,9 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
+21 -22
View File
@@ -23,20 +23,20 @@ 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/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
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.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
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/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/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
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/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
@@ -65,15 +65,15 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s=
github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68=
github.com/google/go-github/v75 v75.0.0 h1:k7q8Bvg+W5KxRl9Tjq16a9XEgVY1pwuiG5sIL7435Ic=
github.com/google/go-github/v75 v75.0.0/go.mod h1:H3LUJEA1TCrzuUqtdAQniBNwuKiQIqdGKgBo1/M/uqI=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@@ -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=
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
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/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
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.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
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/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -158,16 +158,15 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
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.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
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/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+208 -58
View File
@@ -1,6 +1,7 @@
package aggregator
import (
"slices"
"sort"
"strings"
"time"
@@ -72,6 +73,38 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
// Per-repo activity days
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
contributorFiles := make(map[string]map[string]bool) // login -> set of file paths
// Per-repo unique files per contributor
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
getRepoContributor := func(repo, login, name, avatarURL string) *models.ContributorMetrics {
if repoContributorMap[repo] == nil {
@@ -135,31 +168,53 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
cm := contributorMap[login]
cm.CommitCount++
if commit.HasTests {
cm.CommitsWithTests++
}
cm.LinesAdded += commit.Additions
cm.LinesDeleted += commit.Deletions
cm.MeaningfulLinesAdded += commit.MeaningfulAdditions
cm.MeaningfulLinesDeleted += commit.MeaningfulDeletions
cm.CommentLinesAdded += commit.CommentAdditions
cm.CommentLinesDeleted += commit.CommentDeletions
cm.FilesChanged += commit.FilesChanged
// Track unique files (don't sum - we'll count unique files at the end)
if contributorFiles[login] == nil {
contributorFiles[login] = make(map[string]bool)
}
for _, filePath := range commit.FilesModified {
contributorFiles[login][filePath] = true
}
// Update per-repo contributor stats
rcm := getRepoContributor(commit.Repository, login, cm.Name, cm.AvatarURL)
rcm.CommitCount++
if commit.HasTests {
rcm.CommitsWithTests++
}
rcm.LinesAdded += commit.Additions
rcm.LinesDeleted += commit.Deletions
rcm.MeaningfulLinesAdded += commit.MeaningfulAdditions
rcm.MeaningfulLinesDeleted += commit.MeaningfulDeletions
rcm.CommentLinesAdded += commit.CommentAdditions
rcm.CommentLinesDeleted += commit.CommentDeletions
rcm.FilesChanged += commit.FilesChanged
// Track unique files per repo (don't sum - we'll count unique files at the end)
if repoContributorFiles[commit.Repository] == nil {
repoContributorFiles[commit.Repository] = make(map[string]map[string]bool)
}
if repoContributorFiles[commit.Repository][login] == nil {
repoContributorFiles[commit.Repository][login] = make(map[string]bool)
}
for _, filePath := range commit.FilesModified {
repoContributorFiles[commit.Repository][login][filePath] = true
}
// Track activity patterns based on commit time
hour := commit.Date.Hour()
weekday := commit.Date.Weekday()
// Early bird: commits before 9am (for achievements)
if hour >= 5 && hour < 9 {
// Early bird: commits between 6am-9am (for achievements)
// Aligned with the early morning multiplier range
if hour >= 6 && hour < 9 {
cm.EarlyBirdCount++
rcm.EarlyBirdCount++
}
@@ -213,24 +268,11 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
rcm.EarlyMorningCount++
}
// Track activity days (global)
if activityDays[login] == nil {
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 activity day for this commit
trackActivityDay(login, commit.Repository, commit.Date)
// Track repository participation
if !contains(cm.RepositoriesContributed, commit.Repository) {
if !slices.Contains(cm.RepositoriesContributed, commit.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, commit.Repository)
}
@@ -253,6 +295,13 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
}
}
// Calculate unique files changed for each contributor
for login, files := range contributorFiles {
if cm, ok := contributorMap[login]; ok {
cm.FilesChanged = len(files)
}
}
// Track PRs with changes requested per contributor
prChangesRequested := make(map[string]map[int]bool) // login -> set of PR numbers with changes requested
@@ -280,6 +329,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
rcm := getRepoContributor(pr.Repository, login, cm.Name, cm.AvatarURL)
rcm.PRsOpened++
// Track activity day for PR creation
trackActivityDay(login, pr.Repository, pr.CreatedAt)
prSize := pr.Additions + pr.Deletions
if pr.IsMerged() {
@@ -289,6 +341,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
// Accumulate for average calculation
cm.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
@@ -310,7 +368,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
}
// Track repository participation
if !contains(cm.RepositoriesContributed, pr.Repository) {
if !slices.Contains(cm.RepositoriesContributed, pr.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, pr.Repository)
}
@@ -345,6 +403,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
rcm.ReviewsGiven++
rcm.ReviewComments += review.CommentsCount
// Track activity day for review submission
trackActivityDay(login, review.Repository, review.SubmittedAt)
if review.IsApproval() {
cm.ApprovalsGiven++
rcm.ApprovalsGiven++
@@ -368,6 +429,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
if review.ResponseTime != nil {
cm.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
@@ -425,21 +492,47 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
cm := contributorMap[login]
cm.IssuesOpened++
if issue.IsClosed() && issue.ClosedBy != nil && issue.ClosedBy.Login == login {
cm.IssuesClosed++
}
// Track activity day for issue creation
trackActivityDay(login, issue.Repository, issue.CreatedAt)
// Track repository participation
if !contains(cm.RepositoriesContributed, issue.Repository) {
if !slices.Contains(cm.RepositoriesContributed, issue.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, issue.Repository)
}
// Update per-repo contributor metrics
rcm := getRepoContributor(issue.Repository, login, cm.Name, cm.AvatarURL)
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
@@ -460,8 +553,11 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
cm := contributorMap[login]
cm.IssueComments++
// Track activity day for issue comment
trackActivityDay(login, comment.Repository, comment.CreatedAt)
// Track repository participation
if !contains(cm.RepositoriesContributed, comment.Repository) {
if !slices.Contains(cm.RepositoriesContributed, comment.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, comment.Repository)
}
@@ -523,20 +619,23 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
// Calculate averages and finalize contributor metrics
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 {
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
for _, pr := range data.PullRequests {
if !pr.IsMerged() {
continue // Only count merged PRs
}
// Normalize PR author login before comparison
prLogin := pr.Author.Login
if normalized, ok := prAuthorToNormalizedLogin[prLogin]; ok {
@@ -546,7 +645,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
totalPRLines += pr.TotalChanges()
}
}
cm.AvgPRSize = float64(totalPRLines) / float64(cm.PRsOpened)
cm.AvgPRSize = float64(totalPRLines) / float64(cm.PRsMerged)
}
// Set unique reviewees count
@@ -579,19 +678,37 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
}
}
// Calculate unique files changed for per-repo contributors
if repoFiles, ok := repoContributorFiles[repo]; ok {
for login, files := range repoFiles {
if rcm, ok := repoContribs[login]; ok {
rcm.FilesChanged = len(files)
}
}
}
// Calculate averages for per-repo contributors
for login, rcm := range repoContribs {
if rcm.PRsMerged > 0 {
rcm.AvgTimeToMerge = rcm.AvgTimeToMerge / float64(rcm.PRsMerged)
// Use count of PRs with valid time data for accurate average
if repoPRCounts, ok := repoPRsWithTimeToMerge[repo]; ok {
if count := repoPRCounts[login]; count > 0 {
rcm.AvgTimeToMerge = rcm.AvgTimeToMerge / float64(count)
}
}
if rcm.ReviewsGiven > 0 {
rcm.AvgReviewTime = rcm.AvgReviewTime / float64(rcm.ReviewsGiven)
// 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)
}
}
// Calculate average PR size for this repo
if rcm.PRsOpened > 0 {
// Calculate average PR size for this repo (only for merged PRs to exclude abandoned PRs)
if rcm.PRsMerged > 0 {
totalPRLines := 0
for _, pr := range data.PullRequests {
if !pr.IsMerged() {
continue // Only count merged PRs
}
// Normalize PR author login before comparison
prLogin := pr.Author.Login
if mapped, ok := loginToLogin[prLogin]; ok {
@@ -601,7 +718,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
totalPRLines += pr.TotalChanges()
}
}
rcm.AvgPRSize = float64(totalPRLines) / float64(rcm.PRsOpened)
rcm.AvgPRSize = float64(totalPRLines) / float64(rcm.PRsMerged)
}
// Calculate perfect PRs for this repo
@@ -725,15 +842,6 @@ func parseRepoName(fullName string) (owner, name string) {
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
// by lowercasing and removing spaces, hyphens, underscores, dots, and digits
func normalizeForComparison(s string) string {
@@ -1246,7 +1354,47 @@ func buildVelocityTimeline(data *models.RawData, period models.Period, scoringCo
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 {
if commit.Date.Before(start) || commit.Date.After(end) {
continue
@@ -1254,7 +1402,9 @@ func buildVelocityTimeline(data *models.RawData, period models.Period, scoringCo
idx := findWeekIndex(commit.Date)
if idx >= 0 && idx < len(weeks) {
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)
}
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) {
t.Parallel()
+108 -2
View File
@@ -5,19 +5,22 @@ import (
)
// IsCommentLine checks if a line is a code comment (should not count as meaningful contribution)
// Note: Empty/whitespace lines are NOT comments - use IsWhitespaceLine for those.
func IsCommentLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return true // Empty lines don't count
return false // Empty lines are whitespace, not comments
}
// Common comment patterns across languages
// Order matters for overlapping prefixes (e.g., "///" before "//")
commentPrefixes := []string{
"///", // Rust/Swift/C# doc comments
"//", // C, C++, Java, Go, JS, TS, Swift, Kotlin, etc.
"#", // Python, Ruby, Shell, YAML, Perl, etc.
"/**", // JSDoc/JavaDoc block start
"/*", // C-style block comment start
"*/", // C-style block comment end
"*", // C-style block comment continuation
"<!--", // HTML/XML comment
"-->", // HTML/XML comment end
"--", // SQL, Lua, Haskell
@@ -33,6 +36,19 @@ func IsCommentLine(line string) bool {
}
}
// C-style block comment continuation: line starts with * followed by space or end of line
// This avoids false positives like "*ptr = value" (pointer dereference)
if strings.HasPrefix(trimmed, "*") {
if len(trimmed) == 1 {
return true // Just "*" alone
}
// Must be followed by whitespace or common comment characters, not alphanumeric
nextChar := trimmed[1]
if nextChar == ' ' || nextChar == '\t' || nextChar == '/' {
return true
}
}
return false
}
@@ -64,6 +80,96 @@ func IsMeaningfulLine(line string) bool {
return !IsWhitespaceLine(line) && !IsCommentLine(line)
}
// IsDocCommentLine checks if a line is a documentation comment (JSDoc, JavaDoc, Rust doc, etc.)
// These are comments specifically meant to document code, as opposed to regular comments.
func IsDocCommentLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return false
}
// Documentation comment patterns
docPrefixes := []string{
"///", // Rust, Swift, C# doc comments
"//!", // Rust inner doc comments
"/**", // JSDoc, JavaDoc block start
"\"\"\"", // Python docstring
"'''", // Python docstring
}
for _, prefix := range docPrefixes {
if strings.HasPrefix(trimmed, prefix) {
return true
}
}
// JSDoc/JavaDoc continuation lines with annotations (@param, @return, etc.)
if strings.HasPrefix(trimmed, "* @") || strings.HasPrefix(trimmed, "* @") {
return true
}
// Check for common doc annotations at the start of a comment
if strings.HasPrefix(trimmed, "// @") || strings.HasPrefix(trimmed, "# @") {
return true
}
return false
}
// IsCommentedOutCode attempts to detect if a comment line contains commented-out code
// rather than an actual comment. This is a heuristic and may have false positives/negatives.
func IsCommentedOutCode(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return false
}
// Remove comment prefix to get the content
var content string
commentPrefixes := []string{"///", "//", "#", "/*", "--", ";"}
for _, prefix := range commentPrefixes {
if strings.HasPrefix(trimmed, prefix) {
content = strings.TrimSpace(trimmed[len(prefix):])
break
}
}
if content == "" {
return false
}
// Heuristics for detecting commented-out code:
// 1. Ends with common code patterns
codeEndings := []string{";", "{", "}", ")", ",", ":", "=>", "->"}
for _, ending := range codeEndings {
if strings.HasSuffix(content, ending) {
return true
}
}
// 2. Starts with common code keywords
codeKeywords := []string{
"if ", "else ", "for ", "while ", "switch ", "case ", "return ", "break", "continue",
"const ", "let ", "var ", "func ", "function ", "def ", "class ", "struct ", "type ",
"import ", "from ", "package ", "public ", "private ", "protected ", "static ",
"async ", "await ", "try ", "catch ", "throw ", "raise ",
}
contentLower := strings.ToLower(content)
for _, keyword := range codeKeywords {
if strings.HasPrefix(contentLower, keyword) {
return true
}
}
// 3. Contains assignment operators
if strings.Contains(content, " = ") || strings.Contains(content, " := ") ||
strings.Contains(content, " == ") || strings.Contains(content, " != ") {
return true
}
return false
}
// IsRenameOrMove checks if a file change represents a rename or move operation
// rather than actual content modification. A rename/move is detected when both
// the source (fromName) and destination (toName) paths exist and differ.
+180 -5
View File
@@ -12,11 +12,11 @@ func TestIsCommentLine(t *testing.T) {
line string
expected bool
}{
// Empty and whitespace
{"empty string", "", true},
{"whitespace only", " ", true},
{"tab only", "\t", true},
{"mixed whitespace", " \t ", true},
// Empty and whitespace - NOT comments (use IsWhitespaceLine instead)
{"empty string", "", false},
{"whitespace only", " ", false},
{"tab only", "\t", false},
{"mixed whitespace", " \t ", false},
// C-style comments (Go, Java, JS, C++, etc.)
{"C single line comment", "// this is a comment", true},
@@ -25,6 +25,18 @@ func TestIsCommentLine(t *testing.T) {
{"C block end", "*/", true},
{"C block continuation", "* continuation", true},
{"C block continuation with space", " * continuation", true},
{"just asterisk", "*", true},
{"asterisk with slash", "*/", true},
// Pointer dereferences - NOT comments
{"pointer dereference", "*ptr = value", false},
{"pointer in expression", "*foo.bar", false},
{"multiplication", "*result", false},
// Doc comments
{"Rust doc comment", "/// This documents the function", true},
{"Rust inner doc", "//! Module documentation", true},
{"JSDoc start", "/** @param x the value */", true},
// Python/Shell comments
{"Python comment", "# python comment", true},
@@ -61,6 +73,70 @@ func TestIsCommentLine(t *testing.T) {
{"Function call", "fmt.Println(x)", false},
{"String with slash", `"http://example.com"`, false},
{"Code after whitespace", " x := 5", false},
// Indented code (common in diffs) - NOT comments
{"tab indented code", "\tfunc main() {", false},
{"space indented code", " if x > 0 {", false},
{"deeply indented", "\t\t\t\treturn nil", false},
{"mixed indentation", " \t for i := range items {", false},
{"indented closing brace", "\t}", false},
{"indented method call", " obj.Method()", false},
// TypeScript/JavaScript specific - NOT comments
{"TS interface", "interface User {", false},
{"TS type alias", "type Handler = () => void;", false},
{"TS arrow function", "const fn = () => {", false},
{"TS arrow function with type", "const fn = (x: number): string => {", false},
{"JS const", "const x = 5;", false},
{"JS let", "let counter = 0;", false},
{"JS async", "async function fetch() {", false},
{"JS await", "const result = await fetch(url);", false},
{"JS template literal", "const msg = `Hello ${name}`;", false},
{"JS export", "export default Component;", false},
{"JS import", "import { useState } from 'react';", false},
{"TS generic", "function identity<T>(arg: T): T {", false},
{"React JSX", "<Component prop={value} />", false},
{"JSX with children", "<div className=\"container\">", false},
// TypeScript/JavaScript comments
{"TS comment", "// TypeScript comment", true},
{"JSDoc block", "/** @type {string} */", true},
{"TSDoc", "/** @param name - the user name */", true},
// Go specific - NOT comments
{"Go struct", "type User struct {", false},
{"Go interface def", "type Reader interface {", false},
{"Go func with receiver", "func (u *User) Name() string {", false},
{"Go goroutine", "go processItem(item)", false},
{"Go defer", "defer file.Close()", false},
{"Go channel send", "ch <- value", false},
{"Go channel receive", "value := <-ch", false},
{"Go select", "select {", false},
{"Go case", "case <-done:", false},
{"Go map literal", "m := map[string]int{}", false},
{"Go slice literal", "s := []int{1, 2, 3}", false},
{"Go error handling", "if err != nil {", false},
{"Go short var decl", "x := 5", false},
{"Go range", "for i, v := range items {", false},
// Python specific - NOT comments
{"Python def", "def main():", false},
{"Python class", "class User:", false},
{"Python async def", "async def fetch():", false},
{"Python decorator", "@property", false},
{"Python with", "with open('file') as f:", false},
{"Python try", "try:", false},
{"Python except", "except ValueError as e:", false},
{"Python lambda", "fn = lambda x: x * 2", false},
{"Python list comp", "squares = [x**2 for x in range(10)]", false},
{"Python dict comp", "d = {k: v for k, v in items}", false},
{"Python f-string", "msg = f\"Hello {name}\"", false},
{"Python import from", "from typing import List", false},
{"Python type hint", "def greet(name: str) -> str:", false},
// Python comments
{"Python comment with hash", "# This is a comment", true},
{"Python inline comment would be code", "x = 5 # inline", false}, // The line starts with code
}
for _, tt := range tests {
@@ -158,6 +234,17 @@ func TestIsMeaningfulLine(t *testing.T) {
{"whitespace line", " ", false},
{"python comment", "# comment", false},
{"code with leading whitespace", " x := 5", true},
// Indented code is still meaningful
{"tab indented code", "\tfunc main() {", true},
{"deeply indented code", "\t\t\treturn result", true},
{"space indented code", " if err != nil {", true},
{"mixed indentation code", " \t for _, item := range items {", true},
{"indented closing brace", "\t\t}", true},
// Indented comments are still comments (not meaningful)
{"indented comment", "\t// TODO: fix this", false},
{"space indented comment", " # Python comment", false},
}
for _, tt := range tests {
@@ -202,3 +289,91 @@ func TestIsRenameOrMove(t *testing.T) {
})
}
}
func TestIsDocCommentLine(t *testing.T) {
tests := []struct {
name string
line string
expected bool
}{
// Documentation comments
{"Rust doc comment", "/// This documents the function", true},
{"Rust doc with leading space", " /// This documents the function", true},
{"Rust inner doc", "//! Module documentation", true},
{"JSDoc block start", "/** @param x the value */", true},
{"JSDoc block start with space", " /** @param x */", true},
{"Python docstring double", "\"\"\"This is a docstring", true},
{"Python docstring single", "'''This is a docstring", true},
{"JSDoc annotation line", "* @param x the value", true},
{"JSDoc annotation with extra space", "* @returns the result", true},
{"annotation comment", "// @deprecated use newFunc instead", true},
{"Python annotation", "# @param x the value", true},
// Regular comments - NOT doc comments
{"regular C comment", "// this is a comment", false},
{"regular Python comment", "# just a comment", false},
{"block comment start", "/* start of block */", false},
{"block continuation", "* continuation without annotation", false},
// Empty and whitespace
{"empty string", "", false},
{"whitespace only", " ", false},
// Code - NOT doc comments
{"Go code", "func main() {", false},
{"Python code", "def main():", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsDocCommentLine(tt.line)
assert.Equal(t, tt.expected, result, "IsDocCommentLine(%q)", tt.line)
})
}
}
func TestIsCommentedOutCode(t *testing.T) {
tests := []struct {
name string
line string
expected bool
}{
// Commented-out code - should return true
{"commented variable declaration", "// const x = 5;", true},
{"commented function call", "// fmt.Println(x)", true}, // Ends with )
{"commented function def", "// func main() {", true},
{"commented return", "// return nil", true},
{"commented import", "// import fmt", true},
{"commented if statement", "// if x > 0 {", true},
{"commented else", "// else {", true},
{"commented for loop", "// for i := 0; i < 10; i++ {", true},
{"commented assignment", "// x = 10", true}, // Contains = operator
{"commented with equals", "// x = y + 10;", true}, // Ends with ;
{"Python commented code", "# def main():", true}, // colon at end
{"commented arrow function", "// const fn = () => {", true},
{"commented Go assignment", "// x := 5", true},
// Regular comments - should return false
{"todo comment", "// TODO: fix this", false},
{"note comment", "// Note: this is important", false},
{"explanation comment", "// This function handles errors", false},
{"section comment", "// ============", false},
{"url in comment", "// See https://example.com", false},
// Empty and edge cases
{"empty string", "", false},
{"just comment prefix", "//", false},
{"whitespace only", " ", false},
// Code (not commented) - should return false
{"actual code", "const x = 5;", false},
{"actual function", "func main() {", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsCommentedOutCode(tt.line)
assert.Equal(t, tt.expected, result, "IsCommentedOutCode(%q)", tt.line)
})
}
}
+20 -11
View File
@@ -4,25 +4,34 @@ import "time"
// Commit represents a Git commit
type Commit struct {
SHA string `json:"sha"`
Message string `json:"message"`
Author Author `json:"author"`
Committer Author `json:"committer"`
Date time.Time `json:"date"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"`
Repository string `json:"repository"` // owner/repo format
URL string `json:"url"`
SHA string `json:"sha"`
Message string `json:"message"`
Author Author `json:"author"`
Committer Author `json:"committer"`
Date time.Time `json:"date"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"`
FilesModified []string `json:"files_modified,omitempty"` // List of file paths modified in this commit
Repository string `json:"repository"` // owner/repo format
URL string `json:"url"`
// Meaningful line counts (excludes comments and whitespace)
MeaningfulAdditions int `json:"meaningful_additions"`
MeaningfulDeletions int `json:"meaningful_deletions"`
// Comment line counts
// Comment line counts (all types of comments)
CommentAdditions int `json:"comment_additions"`
CommentDeletions int `json:"comment_deletions"`
// Documentation comment counts (JSDoc, Rust doc comments, docstrings, etc.)
DocCommentAdditions int `json:"doc_comment_additions"`
DocCommentDeletions int `json:"doc_comment_deletions"`
// Commented-out code counts (code that was commented rather than deleted)
CommentedCodeAdditions int `json:"commented_code_additions"`
CommentedCodeDeletions int `json:"commented_code_deletions"`
// Derived fields
HasTests bool `json:"has_tests"`
}
+7 -5
View File
@@ -18,10 +18,11 @@ type ContributorMetrics struct {
Period Period `json:"period"`
// Commit metrics
CommitCount int `json:"commit_count"`
LinesAdded int `json:"lines_added"`
LinesDeleted int `json:"lines_deleted"`
FilesChanged int `json:"files_changed"`
CommitCount int `json:"commit_count"`
CommitsWithTests int `json:"commits_with_tests"` // Commits that include test files
LinesAdded int `json:"lines_added"`
LinesDeleted int `json:"lines_deleted"`
FilesChanged int `json:"files_changed"`
// Meaningful line counts (excludes comments and whitespace)
MeaningfulLinesAdded int `json:"meaningful_lines_added"`
@@ -98,7 +99,8 @@ type ScoreBreakdown struct {
Issues int `json:"issues"` // Issue-related points (opened, closed, comments, references)
ResponseBonus int `json:"response_bonus"`
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
+74 -56
View File
@@ -1,6 +1,7 @@
package scoring
import (
"slices"
"sort"
"github.com/lukaszraczylo/git-velocity/internal/config"
@@ -23,52 +24,73 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
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)
for _, repo := range metrics.Repositories {
for i := range repo.Contributors {
login := repo.Contributors[i].Login
if _, ok := contributorMap[login]; !ok {
// Copy the contributor metrics
cm := repo.Contributors[i]
contributorMap[login] = &cm
} else {
// Aggregate metrics from multiple repos
existing := contributorMap[login]
cm := repo.Contributors[i]
existing.CommitCount += cm.CommitCount
existing.LinesAdded += cm.LinesAdded
existing.LinesDeleted += cm.LinesDeleted
existing.MeaningfulLinesAdded += cm.MeaningfulLinesAdded
existing.MeaningfulLinesDeleted += cm.MeaningfulLinesDeleted
existing.CommentLinesAdded += cm.CommentLinesAdded
existing.CommentLinesDeleted += cm.CommentLinesDeleted
existing.PRsOpened += cm.PRsOpened
existing.PRsMerged += cm.PRsMerged
existing.ReviewsGiven += cm.ReviewsGiven
existing.ReviewComments += cm.ReviewComments
// Issue metrics
existing.IssuesOpened += cm.IssuesOpened
existing.IssuesClosed += cm.IssuesClosed
existing.IssueComments += cm.IssueComments
existing.IssueReferencesInCommits += cm.IssueReferencesInCommits
// Activity pattern metrics (for achievements)
existing.EarlyBirdCount += cm.EarlyBirdCount
existing.NightOwlCount += cm.NightOwlCount
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 !contains(existing.RepositoriesContributed, r) {
existing.RepositoriesContributed = append(existing.RepositoriesContributed, r)
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 i := range repo.Contributors {
login := repo.Contributors[i].Login
if _, ok := contributorMap[login]; !ok {
// Copy the contributor metrics
cm := repo.Contributors[i]
contributorMap[login] = &cm
} else {
// Aggregate metrics from multiple repos
existing := contributorMap[login]
cm := repo.Contributors[i]
existing.CommitCount += cm.CommitCount
existing.CommitsWithTests += cm.CommitsWithTests
existing.LinesAdded += cm.LinesAdded
existing.LinesDeleted += cm.LinesDeleted
existing.MeaningfulLinesAdded += cm.MeaningfulLinesAdded
existing.MeaningfulLinesDeleted += cm.MeaningfulLinesDeleted
existing.CommentLinesAdded += cm.CommentLinesAdded
existing.CommentLinesDeleted += cm.CommentLinesDeleted
existing.PRsOpened += cm.PRsOpened
existing.PRsMerged += cm.PRsMerged
existing.ReviewsGiven += cm.ReviewsGiven
existing.ReviewComments += cm.ReviewComments
// Issue metrics
existing.IssuesOpened += cm.IssuesOpened
existing.IssuesClosed += cm.IssuesClosed
existing.IssueComments += cm.IssueComments
existing.IssueReferencesInCommits += cm.IssueReferencesInCommits
// Activity pattern metrics (for achievements)
existing.EarlyBirdCount += cm.EarlyBirdCount
existing.NightOwlCount += cm.NightOwlCount
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)
breakdown.OutOfHours = cm.OutOfHoursCount * points.OutOfHours
// Calculate total
total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs +
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments +
breakdown.Issues + breakdown.OutOfHours
breakdown.Issues + breakdown.TestsBonus + breakdown.OutOfHours
return models.Score{
Total: total,
@@ -293,9 +318,11 @@ func (c *Calculator) checkAchievements(cm *models.ContributorMetrics) []string {
case "comment_count":
earned = float64(cm.ReviewComments) >= ach.Condition.Threshold
case "lines_added":
earned = float64(cm.LinesAdded) >= ach.Condition.Threshold
// Use meaningful lines to match scoring calculation (excludes comments/whitespace)
earned = float64(cm.MeaningfulLinesAdded) >= ach.Condition.Threshold
case "lines_deleted":
earned = float64(cm.LinesDeleted) >= ach.Condition.Threshold
// Use meaningful lines to match scoring calculation (excludes comments/whitespace)
earned = float64(cm.MeaningfulLinesDeleted) >= ach.Condition.Threshold
case "avg_review_time_hours":
// For avg review time, lower is better, so lower threshold = harder achievement
if cm.AvgReviewTime > 0 && cm.AvgReviewTime <= ach.Condition.Threshold {
@@ -404,12 +431,3 @@ func (c *Calculator) findTopAchievers(contributors []models.ContributorMetrics,
topAchievers["pull_requests"] = topPRAuthor
}
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
+2 -12
View File
@@ -452,6 +452,8 @@ func TestCalculator_AllAchievementTypes(t *testing.T) {
ReviewComments: 25,
LinesAdded: 1500,
LinesDeleted: 600,
MeaningfulLinesAdded: 1500,
MeaningfulLinesDeleted: 600,
AvgReviewTime: 1.5,
UniqueReviewees: 7,
RepositoriesContributed: []string{"owner/repo1", "owner/repo2"},
@@ -841,18 +843,6 @@ func TestCalculator_WorkWeekStreakAchievement(t *testing.T) {
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) {
t.Parallel()
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script type="module" crossorigin src="./assets/index-BERDeI1q.js"></script>
<script type="module" crossorigin src="./assets/index-CEo220ix.js"></script>
<link rel="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js">
<link rel="stylesheet" crossorigin href="./assets/index-Dolyd9gm.css">
</head>
+64 -31
View File
@@ -310,17 +310,22 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
Name: c.Committer.Name,
Email: c.Committer.Email,
},
Date: commitTime,
Additions: stats.Additions,
Deletions: stats.Deletions,
MeaningfulAdditions: stats.MeaningfulAdditions,
MeaningfulDeletions: stats.MeaningfulDeletions,
CommentAdditions: stats.CommentAdditions,
CommentDeletions: stats.CommentDeletions,
FilesChanged: stats.FilesChanged,
Repository: fmt.Sprintf("%s/%s", owner, name),
URL: fmt.Sprintf("https://github.com/%s/%s/commit/%s", owner, name, c.Hash.String()),
HasTests: stats.HasTests,
Date: commitTime,
Additions: stats.Additions,
Deletions: stats.Deletions,
MeaningfulAdditions: stats.MeaningfulAdditions,
MeaningfulDeletions: stats.MeaningfulDeletions,
CommentAdditions: stats.CommentAdditions,
CommentDeletions: stats.CommentDeletions,
DocCommentAdditions: stats.DocCommentAdditions,
DocCommentDeletions: stats.DocCommentDeletions,
CommentedCodeAdditions: stats.CommentedCodeAdditions,
CommentedCodeDeletions: stats.CommentedCodeDeletions,
FilesChanged: stats.FilesChanged,
FilesModified: stats.FilesModified,
Repository: fmt.Sprintf("%s/%s", owner, name),
URL: fmt.Sprintf("https://github.com/%s/%s/commit/%s", owner, name, c.Hash.String()),
HasTests: stats.HasTests,
}
commits = append(commits, commit)
@@ -353,14 +358,19 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
// commitStats holds the statistics for a commit
type commitStats struct {
Additions int
Deletions int
MeaningfulAdditions int
MeaningfulDeletions int
CommentAdditions int
CommentDeletions int
FilesChanged int
HasTests bool
Additions int
Deletions int
MeaningfulAdditions int
MeaningfulDeletions int
CommentAdditions int
CommentDeletions int
DocCommentAdditions int
DocCommentDeletions int
CommentedCodeAdditions int
CommentedCodeDeletions int
FilesChanged int
FilesModified []string // List of file paths modified
HasTests bool
}
// getCommitStats calculates additions, deletions, files changed for a commit
@@ -397,12 +407,7 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
filesSet := make(map[string]bool)
for _, change := range changes {
// Skip rename/move operations - they don't represent actual code contribution
if diff.IsRenameOrMove(change.From.Name, change.To.Name) {
continue
}
// Get the file path
// Get the file path (prefer destination for renames/moves, fallback to source)
var filePath string
if change.To.Name != "" {
filePath = change.To.Name
@@ -410,15 +415,24 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
filePath = change.From.Name
}
// Skip documentation files
// Skip if no file path (shouldn't happen, but defensive)
if filePath == "" {
continue
}
// Skip documentation files entirely
if diff.IsDocumentationFile(filePath) {
continue
}
// Count unique files
if !filesSet[filePath] {
// Check if this is a rename/move operation
isRename := diff.IsRenameOrMove(change.From.Name, change.To.Name)
// Count unique files (but NOT for renames - the file already existed)
if !isRename && !filesSet[filePath] {
filesSet[filePath] = true
stats.FilesChanged++
stats.FilesModified = append(stats.FilesModified, filePath)
// Check for test files
for _, pattern := range testPatterns {
@@ -429,13 +443,18 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
}
}
// Get patch to count lines
// Get patch to count lines (even for renames, there may be content changes)
patch, err := change.Patch()
if err != nil {
continue
}
for _, filePatch := range patch.FilePatches() {
// For binary files, skip line counting
if filePatch.IsBinary() {
continue
}
for _, chunk := range filePatch.Chunks() {
content := chunk.Content()
lines := strings.Split(content, "\n")
@@ -446,18 +465,32 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
stats.Additions++
if diff.IsMeaningfulLine(line) {
stats.MeaningfulAdditions++
} else if diff.IsCommentLine(line) && !diff.IsWhitespaceLine(line) {
} else if diff.IsCommentLine(line) {
stats.CommentAdditions++
// Further classify the comment type
if diff.IsDocCommentLine(line) {
stats.DocCommentAdditions++
} else if diff.IsCommentedOutCode(line) {
stats.CommentedCodeAdditions++
}
}
// Whitespace lines are neither meaningful nor comments
}
case 2: // Delete
for _, line := range lines {
stats.Deletions++
if diff.IsMeaningfulLine(line) {
stats.MeaningfulDeletions++
} else if diff.IsCommentLine(line) && !diff.IsWhitespaceLine(line) {
} else if diff.IsCommentLine(line) {
stats.CommentDeletions++
// Further classify the comment type
if diff.IsDocCommentLine(line) {
stats.DocCommentDeletions++
} else if diff.IsCommentedOutCode(line) {
stats.CommentedCodeDeletions++
}
}
// Whitespace lines are neither meaningful nor comments
}
}
}
+15 -9
View File
@@ -221,7 +221,7 @@ type gqlPRQuery struct {
TotalCount int
PageInfo PageInfo
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)"`
}
@@ -329,23 +329,29 @@ func (g *GraphQLClient) FetchPRsWithReviews(ctx context.Context, owner, repo str
}
},
ProcessNode: func(node gqlPRNode, repoName string) ([]prWithReviews, bool, bool) {
// Skip if not merged - not counted as "old"
if node.MergedAt == nil {
return nil, false, false
// Determine the relevant date for filtering:
// - For merged PRs: use MergedAt
// - 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
if hardCutoff != nil && mergedAt.Before(*hardCutoff) {
if hardCutoff != nil && relevantDate.Before(*hardCutoff) {
return nil, true, true // Hard stop
}
// 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"
}
if since != nil && mergedAt.Before(*since) {
if since != nil && relevantDate.Before(*since) {
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": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"lint": "eslint src",
"lint:fix": "eslint src --fix"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.17",
@@ -16,9 +18,12 @@
"vue-router": "^4.2.5"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.57.0",
"@vitejs/plugin-vue": "^6.0.2",
"autoprefixer": "^10.4.16",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.6.2",
"postcss": "^8.4.32",
"tailwindcss": "^4.1.17",
"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)
const remainingCount = computed(() => {
const earnedSet = new Set(props.contributor.achievements || [])
let totalUnearned = 0
for (const type of achievementTypes) {
+1 -1
View File
@@ -13,6 +13,6 @@ defineProps({
hover ? 'hover:shadow-lg transition-shadow' : ''
]"
>
<slot />
<slot></slot>
</div>
</template>
-1
View File
@@ -1,7 +1,6 @@
<script setup>
import { RouterLink } from 'vue-router'
import Avatar from './Avatar.vue'
import { formatNumber } from '../composables/formatters'
defineProps({
contributor: {
+5 -5
View File
@@ -51,8 +51,8 @@ const repositories = computed(() => globalData.value?.Repositories || [])
<!-- Mobile Menu Button -->
<button
@click="mobileMenuOpen = !mobileMenuOpen"
class="md:hidden p-2 rounded-lg hover:bg-gray-700 transition"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<i class="fas fa-bars text-gray-200"></i>
</button>
@@ -63,37 +63,37 @@ const repositories = computed(() => globalData.value?.Repositories || [])
<div class="flex flex-col space-y-1">
<RouterLink
to="/"
@click="mobileMenuOpen = false"
:class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path === '/'
? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800'
]"
@click="mobileMenuOpen = false"
>
<i class="fas fa-home mr-3 w-5 text-center"></i>Dashboard
</RouterLink>
<RouterLink
to="/leaderboard"
@click="mobileMenuOpen = false"
:class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path === '/leaderboard'
? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800'
]"
@click="mobileMenuOpen = false"
>
<i class="fas fa-trophy mr-3 w-5 text-center"></i>Leaderboard
</RouterLink>
<RouterLink
to="/how-scoring-works"
@click="mobileMenuOpen = false"
:class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path === '/how-scoring-works'
? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800'
]"
@click="mobileMenuOpen = false"
>
<i class="fas fa-calculator mr-3 w-5 text-center"></i>How Scoring Works
</RouterLink>
@@ -101,13 +101,13 @@ const repositories = computed(() => globalData.value?.Repositories || [])
v-for="repo in repositories"
:key="`${repo.Owner}/${repo.Name}`"
:to="`/repos/${repo.Owner}/${repo.Name}`"
@click="mobileMenuOpen = false"
:class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path.includes(`/repos/${repo.Owner}/${repo.Name}`)
? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800'
]"
@click="mobileMenuOpen = false"
>
<i class="fas fa-code-branch mr-3 w-5 text-center"></i>{{ repo.Name }}
</RouterLink>
+6 -5
View File
@@ -3,6 +3,7 @@ import { RouterLink } from 'vue-router'
import Card from './Card.vue'
import Avatar from './Avatar.vue'
import { formatNumber, slugify } from '../composables/formatters'
import { DEFAULT_TEAM_COLOR } from '../composables/constants'
defineProps({
team: {
@@ -24,19 +25,19 @@ defineProps({
</h3>
<span
class="w-3 h-3 rounded-full"
:style="{ backgroundColor: team.color || '#8b5cf6' }"
:style="{ backgroundColor: team.color || DEFAULT_TEAM_COLOR }"
></span>
</div>
<div class="flex items-center space-x-2 mb-4">
<template v-for="(member, i) in (team.members || []).slice(0, 5)" :key="member">
<Avatar :name="member" size="sm" />
<template v-for="member in (team.member_metrics || []).slice(0, 5)" :key="member.login">
<Avatar :name="member.name || member.login" :src="member.avatar_url" size="sm" />
</template>
<span
v-if="(team.members?.length || 0) > 5"
v-if="(team.member_metrics?.length || 0) > 5"
class="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-gray-300 text-xs font-bold"
>
+{{ team.members.length - 5 }}
+{{ team.member_metrics.length - 5 }}
</span>
</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
*/
export function formatNumber(n) {
if (n === null || n === undefined) return '0'
if (n >= 1000000) {
return (n / 1000000).toFixed(1) + 'M'
if (n >= ONE_MILLION) {
return (n / ONE_MILLION).toFixed(1) + 'M'
}
if (n >= 1000) {
return (n / 1000).toFixed(1) + 'K'
if (n >= ONE_THOUSAND) {
return (n / ONE_THOUSAND).toFixed(1) + 'K'
}
return String(n)
}
@@ -16,14 +24,14 @@ export function formatNumber(n) {
* Format hours as a human-readable duration
*/
export function formatDuration(hours) {
if (hours === null || hours === undefined) return '-'
if (hours === null || hours === undefined || hours <= 0) return '-'
if (hours < 1) {
return Math.round(hours * 60) + 'm'
return Math.round(hours * MINUTES_PER_HOUR) + 'm'
}
if (hours < 24) {
if (hours < HOURS_PER_DAY) {
return hours.toFixed(1) + 'h'
}
return (hours / 24).toFixed(1) + 'd'
return (hours / HOURS_PER_DAY).toFixed(1) + 'd'
}
/**
+16 -2
View File
@@ -79,8 +79,22 @@ async function loadContributor() {
}
onMounted(loadContributor)
watch(() => route.params, loadContributor)
watch(globalData, loadContributor)
// Watch for route changes (navigation to different contributor)
watch(() => route.params.login, (newLogin, oldLogin) => {
if (newLogin && newLogin !== oldLogin) {
loadContributor()
}
})
// Watch for globalData changes, but only reload if we don't have contributor data yet
// This prevents double-loading when both route and globalData change on initial navigation
watch(globalData, (newData, oldData) => {
// Only reload if globalData became available and we have an error or no data
if (newData && !oldData && (error.value || !contributor.value)) {
loadContributor()
}
})
</script>
<template>
+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" />
<label class="flex items-center space-x-2 text-sm text-gray-400 cursor-pointer">
<input
type="checkbox"
v-model="showScoreInChart"
type="checkbox"
class="rounded border-gray-600 text-primary-500 focus:ring-primary-500"
/>
<span>Show Score</span>
+2 -1
View File
@@ -64,7 +64,7 @@ import SectionHeader from '../components/SectionHeader.vue'
Score Formula
</h3>
<div class="bg-gray-900 text-gray-100 p-3 sm:p-4 rounded-lg overflow-x-auto mb-4 -mx-2 sm:mx-0">
<pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Response
<pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Tests + Response
Where:
Commits = sum of (commits x 10 x time_multiplier)
@@ -73,6 +73,7 @@ Where:
Reviews = reviews_given x 30 pts
Comments = review_comments x 5 pts
Issues = (opened x 10) + (closed x 20) + (comments x 5) + (refs x 5) pts
Tests = commits_with_tests x 15 pts
Response = fast review bonus (0-50 pts)
Time Multipliers:
+1 -11
View File
@@ -34,16 +34,6 @@ const tableColumns = [
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden xl:table-cell' },
{ key: 'score', label: 'Score', align: 'right' }
]
const categoryIcon = (category) => {
const icons = {
'Commits': 'fas fa-code-commit text-green-500',
'PRs': 'fas fa-code-pull-request text-blue-500',
'Reviews': 'fas fa-eye text-purple-500',
'Comments': 'fas fa-comment text-orange-500'
}
return icons[category] || ''
}
</script>
<template>
@@ -71,8 +61,8 @@ const categoryIcon = (category) => {
/>
<button
v-if="searchQuery"
@click="searchQuery = ''"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
@click="searchQuery = ''"
>
<i class="fas fa-times"></i>
</button>
+8 -2
View File
@@ -61,7 +61,13 @@ async function loadRepository() {
}
onMounted(loadRepository)
watch(() => route.params, loadRepository)
// Watch for route changes (navigation to different repository)
watch(() => [route.params.owner, route.params.name], ([newOwner, newName], [oldOwner, oldName]) => {
if ((newOwner && newName) && (newOwner !== oldOwner || newName !== oldName)) {
loadRepository()
}
})
</script>
<template>
@@ -132,8 +138,8 @@ watch(() => route.params, loadRepository)
/>
<button
v-if="searchQuery"
@click="searchQuery = ''"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
@click="searchQuery = ''"
>
<i class="fas fa-times"></i>
</button>
+16 -3
View File
@@ -8,6 +8,7 @@ import StatCard from '../components/StatCard.vue'
import MemberCard from '../components/MemberCard.vue'
import SectionHeader from '../components/SectionHeader.vue'
import { slugify } from '../composables/formatters'
import { DEFAULT_TEAM_COLOR } from '../composables/constants'
const route = useRoute()
const globalData = inject('globalData')
@@ -38,8 +39,20 @@ function loadTeam() {
}
onMounted(loadTeam)
watch(() => route.params, loadTeam)
watch(globalData, loadTeam)
// Watch for route changes (navigation to different team)
watch(() => route.params.slug, (newSlug, oldSlug) => {
if (newSlug && newSlug !== oldSlug) {
loadTeam()
}
})
// Watch for globalData changes, but only reload if we don't have team data yet
watch(globalData, (newData, oldData) => {
if (newData && !oldData && (error.value || !team.value)) {
loadTeam()
}
})
</script>
<template>
@@ -56,7 +69,7 @@ watch(globalData, loadTeam)
<template #prefix>
<div
class="w-4 h-4 rounded-full mr-4"
:style="{ backgroundColor: team.color || '#8b5cf6' }"
:style="{ backgroundColor: team.color || DEFAULT_TEAM_COLOR }"
></div>
</template>
</PageHeader>