Compare commits

..

15 Commits

Author SHA1 Message Date
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
lukaszraczylo aedcf87338 Ignore file rename / remove operations as they don't contribute to the codebase. (#1) 2025-12-16 19:11:25 +00:00
31 changed files with 2985 additions and 273 deletions
+1 -2
View File
@@ -16,5 +16,4 @@ jobs:
with: with:
go-version: "1.24" go-version: "1.24"
release-workflow: "release.yml" release-workflow: "release.yml"
secrets: secrets: inherit
pat-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+9 -9
View File
@@ -24,13 +24,13 @@ 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.1 // 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.4.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.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-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // 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-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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.4.0 // 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/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.39.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
) )
+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/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.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY= github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
github.com/clipperhouse/displaywidth v0.6.1/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.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 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/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 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= 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-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 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s=
github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68= 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 h1:k7q8Bvg+W5KxRl9Tjq16a9XEgVY1pwuiG5sIL7435Ic=
github.com/google/go-github/v75 v75.0.0/go.mod h1:H3LUJEA1TCrzuUqtdAQniBNwuKiQIqdGKgBo1/M/uqI= 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.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 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= 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=
@@ -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-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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.39.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=
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 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=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+208 -58
View File
@@ -1,6 +1,7 @@
package aggregator package aggregator
import ( import (
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -72,6 +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
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 // 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 {
@@ -135,31 +168,53 @@ 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
cm.MeaningfulLinesDeleted += commit.MeaningfulDeletions cm.MeaningfulLinesDeleted += commit.MeaningfulDeletions
cm.CommentLinesAdded += commit.CommentAdditions cm.CommentLinesAdded += commit.CommentAdditions
cm.CommentLinesDeleted += commit.CommentDeletions 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 // 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
rcm.MeaningfulLinesDeleted += commit.MeaningfulDeletions rcm.MeaningfulLinesDeleted += commit.MeaningfulDeletions
rcm.CommentLinesAdded += commit.CommentAdditions rcm.CommentLinesAdded += commit.CommentAdditions
rcm.CommentLinesDeleted += commit.CommentDeletions 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 // Track activity patterns based on commit time
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++
} }
@@ -213,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)
} }
@@ -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 // Track PRs with changes requested per contributor
prChangesRequested := make(map[string]map[int]bool) // login -> set of PR numbers with changes requested 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 := 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() {
@@ -289,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
@@ -310,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)
} }
@@ -345,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++
@@ -368,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
@@ -425,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
@@ -460,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)
} }
@@ -523,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 {
@@ -546,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
@@ -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 // 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 {
@@ -601,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
@@ -725,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 {
@@ -1246,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
@@ -1254,7 +1402,9 @@ func buildVelocityTimeline(data *models.RawData, period models.Period, scoringCo
idx := findWeekIndex(commit.Date) idx := findWeekIndex(commit.Date)
if idx >= 0 && idx < len(weeks) { if idx >= 0 && idx < len(weeks) {
weekCommits[idx]++ weekCommits[idx]++
weekScore[idx] += float64(pointsCommit) // Apply time-based multiplier to commit score
multiplier := getTimeMultiplier(commit.Date.Hour())
weekScore[idx] += float64(pointsCommit) * multiplier
} }
} }
-12
View File
@@ -349,18 +349,6 @@ func TestAggregator_MultipleRepositories(t *testing.T) {
assert.Len(t, metrics.Repositories, 2) assert.Len(t, metrics.Repositories, 2)
} }
func TestContains(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
assert.True(t, contains(slice, "a"))
assert.True(t, contains(slice, "b"))
assert.True(t, contains(slice, "c"))
assert.False(t, contains(slice, "d"))
assert.False(t, contains([]string{}, "a"))
}
func TestParseRepoName(t *testing.T) { func TestParseRepoName(t *testing.T) {
t.Parallel() t.Parallel()
+115 -2
View File
@@ -5,19 +5,22 @@ import (
) )
// IsCommentLine checks if a line is a code comment (should not count as meaningful contribution) // 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 { func IsCommentLine(line string) bool {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if trimmed == "" { if trimmed == "" {
return true // Empty lines don't count return false // Empty lines are whitespace, not comments
} }
// Common comment patterns across languages // Common comment patterns across languages
// Order matters for overlapping prefixes (e.g., "///" before "//")
commentPrefixes := []string{ commentPrefixes := []string{
"///", // Rust/Swift/C# doc comments
"//", // C, C++, Java, Go, JS, TS, Swift, Kotlin, etc. "//", // C, C++, Java, Go, JS, TS, Swift, Kotlin, etc.
"#", // Python, Ruby, Shell, YAML, Perl, etc. "#", // Python, Ruby, Shell, YAML, Perl, etc.
"/**", // JSDoc/JavaDoc block start
"/*", // C-style block comment start "/*", // C-style block comment start
"*/", // C-style block comment end "*/", // C-style block comment end
"*", // C-style block comment continuation
"<!--", // HTML/XML comment "<!--", // HTML/XML comment
"-->", // HTML/XML comment end "-->", // HTML/XML comment end
"--", // SQL, Lua, Haskell "--", // 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 return false
} }
@@ -63,3 +79,100 @@ func IsDocumentationFile(filename string) bool {
func IsMeaningfulLine(line string) bool { func IsMeaningfulLine(line string) bool {
return !IsWhitespaceLine(line) && !IsCommentLine(line) 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.
func IsRenameOrMove(fromName, toName string) bool {
return fromName != "" && toName != "" && fromName != toName
}
+215 -5
View File
@@ -12,11 +12,11 @@ func TestIsCommentLine(t *testing.T) {
line string line string
expected bool expected bool
}{ }{
// Empty and whitespace // Empty and whitespace - NOT comments (use IsWhitespaceLine instead)
{"empty string", "", true}, {"empty string", "", false},
{"whitespace only", " ", true}, {"whitespace only", " ", false},
{"tab only", "\t", true}, {"tab only", "\t", false},
{"mixed whitespace", " \t ", true}, {"mixed whitespace", " \t ", false},
// C-style comments (Go, Java, JS, C++, etc.) // C-style comments (Go, Java, JS, C++, etc.)
{"C single line comment", "// this is a comment", true}, {"C single line comment", "// this is a comment", true},
@@ -25,6 +25,18 @@ func TestIsCommentLine(t *testing.T) {
{"C block end", "*/", true}, {"C block end", "*/", true},
{"C block continuation", "* continuation", true}, {"C block continuation", "* continuation", true},
{"C block continuation with space", " * 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/Shell comments
{"Python comment", "# python comment", true}, {"Python comment", "# python comment", true},
@@ -61,6 +73,70 @@ func TestIsCommentLine(t *testing.T) {
{"Function call", "fmt.Println(x)", false}, {"Function call", "fmt.Println(x)", false},
{"String with slash", `"http://example.com"`, false}, {"String with slash", `"http://example.com"`, false},
{"Code after whitespace", " x := 5", 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 { for _, tt := range tests {
@@ -158,6 +234,17 @@ func TestIsMeaningfulLine(t *testing.T) {
{"whitespace line", " ", false}, {"whitespace line", " ", false},
{"python comment", "# comment", false}, {"python comment", "# comment", false},
{"code with leading whitespace", " x := 5", true}, {"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 { for _, tt := range tests {
@@ -167,3 +254,126 @@ func TestIsMeaningfulLine(t *testing.T) {
}) })
} }
} }
func TestIsRenameOrMove(t *testing.T) {
tests := []struct {
name string
fromName string
toName string
expected bool
}{
// Rename/move operations - should return true
{"simple rename", "old.go", "new.go", true},
{"move to subdirectory", "file.go", "pkg/file.go", true},
{"move from subdirectory", "pkg/file.go", "file.go", true},
{"rename in subdirectory", "pkg/old.go", "pkg/new.go", true},
{"move between directories", "src/file.go", "lib/file.go", true},
{"complex path rename", "internal/api/v1/handler.go", "internal/api/v2/handler.go", true},
// NOT rename/move - should return false
{"new file", "", "new.go", false},
{"deleted file", "old.go", "", false},
{"modify same file", "file.go", "file.go", false},
{"both empty", "", "", false},
{"same path different case is not rename", "File.go", "File.go", false},
// Edge cases
{"whitespace in path rename", "my file.go", "my-file.go", true},
{"deeply nested rename", "a/b/c/d/e/f.go", "a/b/c/d/e/g.go", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsRenameOrMove(tt.fromName, tt.toName)
assert.Equal(t, tt.expected, result, "IsRenameOrMove(%q, %q)", tt.fromName, tt.toName)
})
}
}
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 // Commit represents a Git commit
type Commit struct { type Commit struct {
SHA string `json:"sha"` SHA string `json:"sha"`
Message string `json:"message"` Message string `json:"message"`
Author Author `json:"author"` Author Author `json:"author"`
Committer Author `json:"committer"` Committer Author `json:"committer"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
Additions int `json:"additions"` Additions int `json:"additions"`
Deletions int `json:"deletions"` Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"` FilesChanged int `json:"files_changed"`
Repository string `json:"repository"` // owner/repo format FilesModified []string `json:"files_modified,omitempty"` // List of file paths modified in this commit
URL string `json:"url"` Repository string `json:"repository"` // owner/repo format
URL string `json:"url"`
// Meaningful line counts (excludes comments and whitespace) // Meaningful line counts (excludes comments and whitespace)
MeaningfulAdditions int `json:"meaningful_additions"` MeaningfulAdditions int `json:"meaningful_additions"`
MeaningfulDeletions int `json:"meaningful_deletions"` MeaningfulDeletions int `json:"meaningful_deletions"`
// Comment line counts // Comment line counts (all types of comments)
CommentAdditions int `json:"comment_additions"` CommentAdditions int `json:"comment_additions"`
CommentDeletions int `json:"comment_deletions"` 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 // Derived fields
HasTests bool `json:"has_tests"` HasTests bool `json:"has_tests"`
} }
+7 -5
View File
@@ -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
+74 -56
View File
@@ -1,6 +1,7 @@
package scoring package scoring
import ( import (
"slices"
"sort" "sort"
"github.com/lukaszraczylo/git-velocity/internal/config" "github.com/lukaszraczylo/git-velocity/internal/config"
@@ -23,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,
@@ -293,9 +318,11 @@ func (c *Calculator) checkAchievements(cm *models.ContributorMetrics) []string {
case "comment_count": case "comment_count":
earned = float64(cm.ReviewComments) >= ach.Condition.Threshold earned = float64(cm.ReviewComments) >= ach.Condition.Threshold
case "lines_added": 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": 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": case "avg_review_time_hours":
// For avg review time, lower is better, so lower threshold = harder achievement // For avg review time, lower is better, so lower threshold = harder achievement
if cm.AvgReviewTime > 0 && cm.AvgReviewTime <= ach.Condition.Threshold { if cm.AvgReviewTime > 0 && cm.AvgReviewTime <= ach.Condition.Threshold {
@@ -404,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
}
+2 -12
View File
@@ -452,6 +452,8 @@ func TestCalculator_AllAchievementTypes(t *testing.T) {
ReviewComments: 25, ReviewComments: 25,
LinesAdded: 1500, LinesAdded: 1500,
LinesDeleted: 600, LinesDeleted: 600,
MeaningfulLinesAdded: 1500,
MeaningfulLinesDeleted: 600,
AvgReviewTime: 1.5, AvgReviewTime: 1.5,
UniqueReviewees: 7, UniqueReviewees: 7,
RepositoriesContributed: []string{"owner/repo1", "owner/repo2"}, RepositoriesContributed: []string{"owner/repo1", "owner/repo2"},
@@ -841,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()
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 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 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"> <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="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js">
<link rel="stylesheet" crossorigin href="./assets/index-Dolyd9gm.css"> <link rel="stylesheet" crossorigin href="./assets/index-Dolyd9gm.css">
</head> </head>
+64 -26
View File
@@ -310,17 +310,22 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
Name: c.Committer.Name, Name: c.Committer.Name,
Email: c.Committer.Email, Email: c.Committer.Email,
}, },
Date: commitTime, Date: commitTime,
Additions: stats.Additions, Additions: stats.Additions,
Deletions: stats.Deletions, Deletions: stats.Deletions,
MeaningfulAdditions: stats.MeaningfulAdditions, MeaningfulAdditions: stats.MeaningfulAdditions,
MeaningfulDeletions: stats.MeaningfulDeletions, MeaningfulDeletions: stats.MeaningfulDeletions,
CommentAdditions: stats.CommentAdditions, CommentAdditions: stats.CommentAdditions,
CommentDeletions: stats.CommentDeletions, CommentDeletions: stats.CommentDeletions,
FilesChanged: stats.FilesChanged, DocCommentAdditions: stats.DocCommentAdditions,
Repository: fmt.Sprintf("%s/%s", owner, name), DocCommentDeletions: stats.DocCommentDeletions,
URL: fmt.Sprintf("https://github.com/%s/%s/commit/%s", owner, name, c.Hash.String()), CommentedCodeAdditions: stats.CommentedCodeAdditions,
HasTests: stats.HasTests, 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) 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 // commitStats holds the statistics for a commit
type commitStats struct { type commitStats struct {
Additions int Additions int
Deletions int Deletions int
MeaningfulAdditions int MeaningfulAdditions int
MeaningfulDeletions int MeaningfulDeletions int
CommentAdditions int CommentAdditions int
CommentDeletions int CommentDeletions int
FilesChanged int DocCommentAdditions int
HasTests bool 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 // getCommitStats calculates additions, deletions, files changed for a commit
@@ -397,7 +407,7 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
filesSet := make(map[string]bool) filesSet := make(map[string]bool)
for _, change := range changes { for _, change := range changes {
// Get the file path // Get the file path (prefer destination for renames/moves, fallback to source)
var filePath string var filePath string
if change.To.Name != "" { if change.To.Name != "" {
filePath = change.To.Name filePath = change.To.Name
@@ -405,15 +415,24 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
filePath = change.From.Name 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) { if diff.IsDocumentationFile(filePath) {
continue continue
} }
// Count unique files // Check if this is a rename/move operation
if !filesSet[filePath] { 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 filesSet[filePath] = true
stats.FilesChanged++ stats.FilesChanged++
stats.FilesModified = append(stats.FilesModified, filePath)
// Check for test files // Check for test files
for _, pattern := range testPatterns { for _, pattern := range testPatterns {
@@ -424,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() patch, err := change.Patch()
if err != nil { if err != nil {
continue continue
} }
for _, filePatch := range patch.FilePatches() { for _, filePatch := range patch.FilePatches() {
// For binary files, skip line counting
if filePatch.IsBinary() {
continue
}
for _, chunk := range filePatch.Chunks() { for _, chunk := range filePatch.Chunks() {
content := chunk.Content() content := chunk.Content()
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
@@ -441,18 +465,32 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
stats.Additions++ stats.Additions++
if diff.IsMeaningfulLine(line) { if diff.IsMeaningfulLine(line) {
stats.MeaningfulAdditions++ stats.MeaningfulAdditions++
} else if diff.IsCommentLine(line) && !diff.IsWhitespaceLine(line) { } else if diff.IsCommentLine(line) {
stats.CommentAdditions++ 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 case 2: // Delete
for _, line := range lines { for _, line := range lines {
stats.Deletions++ stats.Deletions++
if diff.IsMeaningfulLine(line) { if diff.IsMeaningfulLine(line) {
stats.MeaningfulDeletions++ stats.MeaningfulDeletions++
} else if diff.IsCommentLine(line) && !diff.IsWhitespaceLine(line) { } else if diff.IsCommentLine(line) {
stats.CommentDeletions++ 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 TotalCount int
PageInfo PageInfo PageInfo PageInfo
Nodes []gqlPRNode Nodes []gqlPRNode
} `graphql:"pullRequests(first: 100, after: $cursor, states: [MERGED], orderBy: {field: UPDATED_AT, direction: DESC})"` } `graphql:"pullRequests(first: 100, after: $cursor, states: [OPEN, MERGED, CLOSED], orderBy: {field: UPDATED_AT, direction: DESC})"`
} `graphql:"repository(owner: $owner, name: $repo)"` } `graphql:"repository(owner: $owner, name: $repo)"`
} }
@@ -329,23 +329,29 @@ func (g *GraphQLClient) FetchPRsWithReviews(ctx context.Context, owner, repo str
} }
}, },
ProcessNode: func(node gqlPRNode, repoName string) ([]prWithReviews, bool, bool) { ProcessNode: func(node gqlPRNode, repoName string) ([]prWithReviews, bool, bool) {
// Skip if not merged - not counted as "old" // Determine the relevant date for filtering:
if node.MergedAt == nil { // - For merged PRs: use MergedAt
return nil, false, false // - For closed PRs: use ClosedAt
// - For open PRs: use CreatedAt (they're still active)
var relevantDate time.Time
if node.MergedAt != nil {
relevantDate = *node.MergedAt
} else if node.ClosedAt != nil {
relevantDate = *node.ClosedAt
} else {
relevantDate = node.CreatedAt
} }
mergedAt := *node.MergedAt
// Hard cutoff check - stop entirely if past this date // Hard cutoff check - stop entirely if past this date
if hardCutoff != nil && mergedAt.Before(*hardCutoff) { if hardCutoff != nil && relevantDate.Before(*hardCutoff) {
return nil, true, true // Hard stop return nil, true, true // Hard stop
} }
// Check date range - skip if outside range // Check date range - skip if outside range
if until != nil && mergedAt.After(*until) { if until != nil && relevantDate.After(*until) {
return nil, false, false // Too new, not "old" return nil, false, false // Too new, not "old"
} }
if since != nil && mergedAt.Before(*since) { if since != nil && relevantDate.Before(*since) {
return nil, true, false // Too old - signal for early termination tracking return nil, true, false // Too old - signal for early termination tracking
} }
+42
View File
@@ -0,0 +1,42 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
export default [
js.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
languageOptions: {
globals: {
// Browser globals
window: 'readonly',
document: 'readonly',
fetch: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly'
}
},
rules: {
// Vue specific rules
'vue/multi-word-component-names': 'off', // Allow single-word component names
'vue/max-attributes-per-line': 'off', // Allow multiple attributes per line
'vue/singleline-html-element-content-newline': 'off',
'vue/html-self-closing': ['error', {
html: { void: 'always', normal: 'never', component: 'always' }
}],
// General JS rules
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': 'warn',
'prefer-const': 'error',
'no-var': 'error'
}
},
{
ignores: ['dist/**', 'node_modules/**']
}
]
+6 -1
View File
@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "eslint src",
"lint:fix": "eslint src --fix"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
@@ -16,9 +18,12 @@
"vue-router": "^4.2.5" "vue-router": "^4.2.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.6.2",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"vite": "^7.2.7" "vite": "^7.2.7"
+2100
View File
File diff suppressed because it is too large Load Diff
@@ -234,7 +234,6 @@ const progressItems = computed(() => {
// Get count of remaining achievements (all unearned across all types) // Get count of remaining achievements (all unearned across all types)
const remainingCount = computed(() => { const remainingCount = computed(() => {
const earnedSet = new Set(props.contributor.achievements || [])
let totalUnearned = 0 let totalUnearned = 0
for (const type of achievementTypes) { for (const type of achievementTypes) {
+1 -1
View File
@@ -13,6 +13,6 @@ defineProps({
hover ? 'hover:shadow-lg transition-shadow' : '' hover ? 'hover:shadow-lg transition-shadow' : ''
]" ]"
> >
<slot /> <slot></slot>
</div> </div>
</template> </template>
-1
View File
@@ -1,7 +1,6 @@
<script setup> <script setup>
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import Avatar from './Avatar.vue' import Avatar from './Avatar.vue'
import { formatNumber } from '../composables/formatters'
defineProps({ defineProps({
contributor: { contributor: {
+5 -5
View File
@@ -51,8 +51,8 @@ const repositories = computed(() => globalData.value?.Repositories || [])
<!-- Mobile Menu Button --> <!-- Mobile Menu Button -->
<button <button
@click="mobileMenuOpen = !mobileMenuOpen"
class="md:hidden p-2 rounded-lg hover:bg-gray-700 transition" class="md:hidden p-2 rounded-lg hover:bg-gray-700 transition"
@click="mobileMenuOpen = !mobileMenuOpen"
> >
<i class="fas fa-bars text-gray-200"></i> <i class="fas fa-bars text-gray-200"></i>
</button> </button>
@@ -63,37 +63,37 @@ const repositories = computed(() => globalData.value?.Repositories || [])
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
<RouterLink <RouterLink
to="/" to="/"
@click="mobileMenuOpen = false"
:class="[ :class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors', 'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path === '/' route.path === '/'
? 'bg-primary-900/20 text-primary-400' ? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800' : 'text-gray-200 hover:bg-gray-800'
]" ]"
@click="mobileMenuOpen = false"
> >
<i class="fas fa-home mr-3 w-5 text-center"></i>Dashboard <i class="fas fa-home mr-3 w-5 text-center"></i>Dashboard
</RouterLink> </RouterLink>
<RouterLink <RouterLink
to="/leaderboard" to="/leaderboard"
@click="mobileMenuOpen = false"
:class="[ :class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors', 'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path === '/leaderboard' route.path === '/leaderboard'
? 'bg-primary-900/20 text-primary-400' ? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800' : 'text-gray-200 hover:bg-gray-800'
]" ]"
@click="mobileMenuOpen = false"
> >
<i class="fas fa-trophy mr-3 w-5 text-center"></i>Leaderboard <i class="fas fa-trophy mr-3 w-5 text-center"></i>Leaderboard
</RouterLink> </RouterLink>
<RouterLink <RouterLink
to="/how-scoring-works" to="/how-scoring-works"
@click="mobileMenuOpen = false"
:class="[ :class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors', 'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path === '/how-scoring-works' route.path === '/how-scoring-works'
? 'bg-primary-900/20 text-primary-400' ? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800' : 'text-gray-200 hover:bg-gray-800'
]" ]"
@click="mobileMenuOpen = false"
> >
<i class="fas fa-calculator mr-3 w-5 text-center"></i>How Scoring Works <i class="fas fa-calculator mr-3 w-5 text-center"></i>How Scoring Works
</RouterLink> </RouterLink>
@@ -101,13 +101,13 @@ const repositories = computed(() => globalData.value?.Repositories || [])
v-for="repo in repositories" v-for="repo in repositories"
:key="`${repo.Owner}/${repo.Name}`" :key="`${repo.Owner}/${repo.Name}`"
:to="`/repos/${repo.Owner}/${repo.Name}`" :to="`/repos/${repo.Owner}/${repo.Name}`"
@click="mobileMenuOpen = false"
:class="[ :class="[
'block px-4 py-3 rounded-lg text-base font-medium transition-colors', 'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) route.path.includes(`/repos/${repo.Owner}/${repo.Name}`)
? 'bg-primary-900/20 text-primary-400' ? 'bg-primary-900/20 text-primary-400'
: 'text-gray-200 hover:bg-gray-800' : 'text-gray-200 hover:bg-gray-800'
]" ]"
@click="mobileMenuOpen = false"
> >
<i class="fas fa-code-branch mr-3 w-5 text-center"></i>{{ repo.Name }} <i class="fas fa-code-branch mr-3 w-5 text-center"></i>{{ repo.Name }}
</RouterLink> </RouterLink>
+6 -5
View File
@@ -3,6 +3,7 @@ import { RouterLink } from 'vue-router'
import Card from './Card.vue' import Card from './Card.vue'
import Avatar from './Avatar.vue' import Avatar from './Avatar.vue'
import { formatNumber, slugify } from '../composables/formatters' import { formatNumber, slugify } from '../composables/formatters'
import { DEFAULT_TEAM_COLOR } from '../composables/constants'
defineProps({ defineProps({
team: { team: {
@@ -24,19 +25,19 @@ 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>
<div class="flex items-center space-x-2 mb-4"> <div class="flex items-center space-x-2 mb-4">
<template v-for="(member, i) in (team.members || []).slice(0, 5)" :key="member"> <template v-for="member in (team.member_metrics || []).slice(0, 5)" :key="member.login">
<Avatar :name="member" size="sm" /> <Avatar :name="member.name || member.login" :src="member.avatar_url" size="sm" />
</template> </template>
<span <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" 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> </span>
</div> </div>
+12
View File
@@ -0,0 +1,12 @@
/**
* Application constants
*/
// Default colors
export const DEFAULT_TEAM_COLOR = '#8b5cf6' // Purple - matches accent color palette
// Data paths
export const DATA_BASE_PATH = './data'
export const GLOBAL_DATA_PATH = `${DATA_BASE_PATH}/global.json`
export const CONTRIBUTORS_PATH = `${DATA_BASE_PATH}/contributors`
export const REPOS_PATH = `${DATA_BASE_PATH}/repos`
+16 -8
View File
@@ -1,13 +1,21 @@
// Number formatting thresholds
const ONE_MILLION = 1_000_000
const ONE_THOUSAND = 1_000
// Time conversion constants
const MINUTES_PER_HOUR = 60
const HOURS_PER_DAY = 24
/** /**
* Format a number with K/M suffixes for large values * Format a number with K/M suffixes for large values
*/ */
export function formatNumber(n) { export function formatNumber(n) {
if (n === null || n === undefined) return '0' if (n === null || n === undefined) return '0'
if (n >= 1000000) { if (n >= ONE_MILLION) {
return (n / 1000000).toFixed(1) + 'M' return (n / ONE_MILLION).toFixed(1) + 'M'
} }
if (n >= 1000) { if (n >= ONE_THOUSAND) {
return (n / 1000).toFixed(1) + 'K' return (n / ONE_THOUSAND).toFixed(1) + 'K'
} }
return String(n) return String(n)
} }
@@ -16,14 +24,14 @@ export function formatNumber(n) {
* Format hours as a human-readable duration * Format hours as a human-readable duration
*/ */
export function formatDuration(hours) { export function formatDuration(hours) {
if (hours === null || hours === undefined) return '-' if (hours === null || hours === undefined || hours <= 0) return '-'
if (hours < 1) { if (hours < 1) {
return Math.round(hours * 60) + 'm' return Math.round(hours * MINUTES_PER_HOUR) + 'm'
} }
if (hours < 24) { if (hours < HOURS_PER_DAY) {
return hours.toFixed(1) + 'h' return hours.toFixed(1) + 'h'
} }
return (hours / 24).toFixed(1) + 'd' return (hours / HOURS_PER_DAY).toFixed(1) + 'd'
} }
/** /**
+16 -2
View File
@@ -79,8 +79,22 @@ async function loadContributor() {
} }
onMounted(loadContributor) onMounted(loadContributor)
watch(() => route.params, loadContributor)
watch(globalData, loadContributor) // Watch for route changes (navigation to different contributor)
watch(() => route.params.login, (newLogin, oldLogin) => {
if (newLogin && newLogin !== oldLogin) {
loadContributor()
}
})
// Watch for globalData changes, but only reload if we don't have contributor data yet
// This prevents double-loading when both route and globalData change on initial navigation
watch(globalData, (newData, oldData) => {
// Only reload if globalData became available and we have an error or no data
if (newData && !oldData && (error.value || !contributor.value)) {
loadContributor()
}
})
</script> </script>
<template> <template>
+1 -1
View File
@@ -57,8 +57,8 @@ const showScoreInChart = ref(false)
<SectionHeader title="Velocity Timeline" icon="fas fa-chart-line" icon-color="text-primary-500" /> <SectionHeader title="Velocity Timeline" icon="fas fa-chart-line" icon-color="text-primary-500" />
<label class="flex items-center space-x-2 text-sm text-gray-400 cursor-pointer"> <label class="flex items-center space-x-2 text-sm text-gray-400 cursor-pointer">
<input <input
type="checkbox"
v-model="showScoreInChart" v-model="showScoreInChart"
type="checkbox"
class="rounded border-gray-600 text-primary-500 focus:ring-primary-500" class="rounded border-gray-600 text-primary-500 focus:ring-primary-500"
/> />
<span>Show Score</span> <span>Show Score</span>
+2 -1
View File
@@ -64,7 +64,7 @@ import SectionHeader from '../components/SectionHeader.vue'
Score Formula Score Formula
</h3> </h3>
<div class="bg-gray-900 text-gray-100 p-3 sm:p-4 rounded-lg overflow-x-auto mb-4 -mx-2 sm:mx-0"> <div class="bg-gray-900 text-gray-100 p-3 sm:p-4 rounded-lg overflow-x-auto mb-4 -mx-2 sm:mx-0">
<pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Response <pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Tests + Response
Where: Where:
Commits = sum of (commits x 10 x time_multiplier) Commits = sum of (commits x 10 x time_multiplier)
@@ -73,6 +73,7 @@ Where:
Reviews = reviews_given x 30 pts Reviews = reviews_given x 30 pts
Comments = review_comments x 5 pts Comments = review_comments x 5 pts
Issues = (opened x 10) + (closed x 20) + (comments x 5) + (refs x 5) pts Issues = (opened x 10) + (closed x 20) + (comments x 5) + (refs x 5) pts
Tests = commits_with_tests x 15 pts
Response = fast review bonus (0-50 pts) Response = fast review bonus (0-50 pts)
Time Multipliers: Time Multipliers:
+1 -11
View File
@@ -34,16 +34,6 @@ const tableColumns = [
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden xl:table-cell' }, { key: 'team', label: 'Team', align: 'left', headerClass: 'hidden xl:table-cell' },
{ key: 'score', label: 'Score', align: 'right' } { key: 'score', label: 'Score', align: 'right' }
] ]
const categoryIcon = (category) => {
const icons = {
'Commits': 'fas fa-code-commit text-green-500',
'PRs': 'fas fa-code-pull-request text-blue-500',
'Reviews': 'fas fa-eye text-purple-500',
'Comments': 'fas fa-comment text-orange-500'
}
return icons[category] || ''
}
</script> </script>
<template> <template>
@@ -71,8 +61,8 @@ const categoryIcon = (category) => {
/> />
<button <button
v-if="searchQuery" v-if="searchQuery"
@click="searchQuery = ''"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
@click="searchQuery = ''"
> >
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
+8 -2
View File
@@ -61,7 +61,13 @@ async function loadRepository() {
} }
onMounted(loadRepository) onMounted(loadRepository)
watch(() => route.params, loadRepository)
// Watch for route changes (navigation to different repository)
watch(() => [route.params.owner, route.params.name], ([newOwner, newName], [oldOwner, oldName]) => {
if ((newOwner && newName) && (newOwner !== oldOwner || newName !== oldName)) {
loadRepository()
}
})
</script> </script>
<template> <template>
@@ -132,8 +138,8 @@ watch(() => route.params, loadRepository)
/> />
<button <button
v-if="searchQuery" v-if="searchQuery"
@click="searchQuery = ''"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
@click="searchQuery = ''"
> >
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
+16 -3
View File
@@ -8,6 +8,7 @@ import StatCard from '../components/StatCard.vue'
import MemberCard from '../components/MemberCard.vue' import MemberCard from '../components/MemberCard.vue'
import SectionHeader from '../components/SectionHeader.vue' import SectionHeader from '../components/SectionHeader.vue'
import { slugify } from '../composables/formatters' import { slugify } from '../composables/formatters'
import { DEFAULT_TEAM_COLOR } from '../composables/constants'
const route = useRoute() const route = useRoute()
const globalData = inject('globalData') const globalData = inject('globalData')
@@ -38,8 +39,20 @@ function loadTeam() {
} }
onMounted(loadTeam) onMounted(loadTeam)
watch(() => route.params, loadTeam)
watch(globalData, loadTeam) // Watch for route changes (navigation to different team)
watch(() => route.params.slug, (newSlug, oldSlug) => {
if (newSlug && newSlug !== oldSlug) {
loadTeam()
}
})
// Watch for globalData changes, but only reload if we don't have team data yet
watch(globalData, (newData, oldData) => {
if (newData && !oldData && (error.value || !team.value)) {
loadTeam()
}
})
</script> </script>
<template> <template>
@@ -56,7 +69,7 @@ watch(globalData, loadTeam)
<template #prefix> <template #prefix>
<div <div
class="w-4 h-4 rounded-full mr-4" class="w-4 h-4 rounded-full mr-4"
:style="{ backgroundColor: team.color || '#8b5cf6' }" :style="{ backgroundColor: team.color || DEFAULT_TEAM_COLOR }"
></div> ></div>
</template> </template>
</PageHeader> </PageHeader>