From e9be8b25ae57189c4b29eaa393a397cf634d21d7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=9Eahin=20Akkaya?= <sahin@sahinakkaya.dev>
Date: Fri, 16 Feb 2024 01:21:13 +0300
Subject: [PATCH] Implement contributors graph (#27882)

Continuation of https://github.com/go-gitea/gitea/pull/25439. Fixes #847

Before:
<img width="1296" alt="image"
src="https://github.com/go-gitea/gitea/assets/32161460/24571ac8-b254-43c9-b178-97340f0dc8a9">

----
After:
<img width="1296" alt="image"
src="https://github.com/go-gitea/gitea/assets/32161460/c60b2459-9d10-4d42-8d83-d5ef0f45bf94">

---
#### Overview
This is the implementation of a requested feature: Contributors graph
(#847)

It makes Activity page a multi-tab page and adds a new tab called
Contributors. Contributors tab shows the contribution graphs over time
since the repository existed. It also shows per user contribution graphs
for top 100 contributors. Top 100 is calculated based on the selected
contribution type (commits, additions or deletions).

---
#### Demo
(The demo is a bit old but still a good example to show off the main
features)

<video src="https://github.com/go-gitea/gitea/assets/32161460/9f68103f-8145-4cc2-94bc-5546daae7014" controls width="320" height="240">
  <a href="https://github.com/go-gitea/gitea/assets/32161460/9f68103f-8145-4cc2-94bc-5546daae7014">Download</a>
</video>

#### Features:

- Select contribution type (commits, additions or deletions)
- See overall and per user contribution graphs for the selected
contribution type
- Zoom and pan on graphs to see them in detail
- See top 100 contributors based on the selected contribution type and
selected time range
- Go directly to users' profile by clicking their name if they are
registered gitea users
- Cache the results so that when the same repository is visited again
fetching data will be faster

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: hiifong <i@hiif.ong>
Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: yp05327 <576951401@qq.com>
(cherry picked from commit 21331be30cb8f6c2d8b9dd99f1061623900632b9)
---
 options/locale/locale_en-US.ini               |  12 +
 package-lock.json                             |  67 ++-
 package.json                                  |   5 +
 routers/web/repo/activity.go                  |   2 +
 routers/web/repo/contributors.go              |  44 ++
 routers/web/web.go                            |   4 +
 services/repository/contributors_graph.go     | 319 +++++++++++++
 .../repository/contributors_graph_test.go     |  87 ++++
 templates/repo/activity.tmpl                  | 234 +--------
 templates/repo/contributors.tmpl              |  13 +
 templates/repo/navbar.tmpl                    |   8 +
 templates/repo/pulse.tmpl                     | 227 +++++++++
 web_src/js/components/.eslintrc.yaml          |   4 +
 web_src/js/components/RepoContributors.vue    | 443 ++++++++++++++++++
 web_src/js/features/contributors.js           |  28 ++
 web_src/js/index.js                           |   2 +
 web_src/js/utils/time.js                      |  46 ++
 web_src/js/utils/time.test.js                 |  15 +
 18 files changed, 1330 insertions(+), 230 deletions(-)
 create mode 100644 routers/web/repo/contributors.go
 create mode 100644 services/repository/contributors_graph.go
 create mode 100644 services/repository/contributors_graph_test.go
 create mode 100644 templates/repo/contributors.tmpl
 create mode 100644 templates/repo/navbar.tmpl
 create mode 100644 templates/repo/pulse.tmpl
 create mode 100644 web_src/js/components/RepoContributors.vue
 create mode 100644 web_src/js/features/contributors.js
 create mode 100644 web_src/js/utils/time.js
 create mode 100644 web_src/js/utils/time.test.js

diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index e3707243a5..7446aea4c0 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1957,6 +1957,8 @@ wiki.page_name_desc = Enter a name for this Wiki page. Some special names are: '
 wiki.original_git_entry_tooltip = View original Git file instead of using friendly link.
 
 activity = Activity
+activity.navbar.pulse = Pulse
+activity.navbar.contributors = Contributors
 activity.period.filter_label = Period:
 activity.period.daily = 1 day
 activity.period.halfweekly = 3 days
@@ -2022,6 +2024,16 @@ activity.git_stats_and_deletions = and
 activity.git_stats_deletion_1 = %d deletion
 activity.git_stats_deletion_n = %d deletions
 
+contributors = Contributors
+contributors.contribution_type.filter_label = Contribution type:
+contributors.contribution_type.commits = Commits
+contributors.contribution_type.additions = Additions
+contributors.contribution_type.deletions = Deletions
+contributors.loading_title = Loading contributions...
+contributors.loading_title_failed = Could not load contributions
+contributors.loading_info = This might take a bit…
+contributors.component_failed_to_load = An unexpected error happened.
+
 search = Search
 search.search_repo = Search repository
 search.type.tooltip = Search type
diff --git a/package-lock.json b/package-lock.json
index 62bf36e7b7..764ae51f9d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,8 +19,12 @@
         "add-asset-webpack-plugin": "2.0.1",
         "ansi_up": "6.0.2",
         "asciinema-player": "3.6.3",
+        "chart.js": "4.3.0",
+        "chartjs-adapter-dayjs-4": "1.0.4",
+        "chartjs-plugin-zoom": "2.0.1",
         "clippie": "4.0.6",
         "css-loader": "6.10.0",
+        "dayjs": "1.11.10",
         "dropzone": "6.0.0-beta.2",
         "easymde": "2.18.0",
         "esbuild-loader": "4.0.3",
@@ -47,6 +51,7 @@
         "uint8-to-base64": "0.2.0",
         "vue": "3.4.18",
         "vue-bar-graph": "2.0.0",
+        "vue-chartjs": "5.3.0",
         "vue-loader": "17.4.2",
         "vue3-calendar-heatmap": "2.0.5",
         "webpack": "5.90.1",
@@ -1278,6 +1283,11 @@
         "jsep": "^0.4.0||^1.0.0"
       }
     },
+    "node_modules/@kurkle/color": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
+      "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
+    },
     "node_modules/@mcaptcha/core-glue": {
       "version": "0.1.0-alpha-5",
       "resolved": "https://registry.npmjs.org/@mcaptcha/core-glue/-/core-glue-0.1.0-alpha-5.tgz",
@@ -3329,6 +3339,40 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/chart.js": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.0.tgz",
+      "integrity": "sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==",
+      "dependencies": {
+        "@kurkle/color": "^0.3.0"
+      },
+      "engines": {
+        "pnpm": ">=7"
+      }
+    },
+    "node_modules/chartjs-adapter-dayjs-4": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz",
+      "integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==",
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "chart.js": ">=4.0.1",
+        "dayjs": "^1.9.7"
+      }
+    },
+    "node_modules/chartjs-plugin-zoom": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.0.1.tgz",
+      "integrity": "sha512-ogOmLu6e+Q7E1XWOCOz9YwybMslz9qNfGV2a+qjfmqJYpsw5ZMoRHZBUyW+NGhkpQ5PwwPA/+rikHpBZb7PZuA==",
+      "dependencies": {
+        "hammerjs": "^2.0.8"
+      },
+      "peerDependencies": {
+        "chart.js": ">=3.2.0"
+      }
+    },
     "node_modules/check-error": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
@@ -5868,9 +5912,17 @@
       "dev": true
     },
     "node_modules/gsap": {
-      "version": "3.12.5",
-      "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.12.5.tgz",
-      "integrity": "sha512-srBfnk4n+Oe/ZnMIOXt3gT605BX9x5+rh/prT2F1SsNJsU1XuMiP0E2aptW481OnonOGACZWBqseH5Z7csHxhQ=="
+      "version": "3.12.2",
+      "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.12.2.tgz",
+      "integrity": "sha512-EkYnpG8qHgYBFAwsgsGEqvT1WUidX0tt/ijepx7z8EUJHElykg91RvW1XbkT59T0gZzzszOpjQv7SE41XuIXyQ=="
+    },
+    "node_modules/hammerjs": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
+      "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==",
+      "engines": {
+        "node": ">=0.8.0"
+      }
     },
     "node_modules/has-bigints": {
       "version": "1.0.2",
@@ -10934,6 +10986,15 @@
         "vue": "^3.2.37"
       }
     },
+    "node_modules/vue-chartjs": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.0.tgz",
+      "integrity": "sha512-8XqX0JU8vFZ+WA2/knz4z3ThClduni2Nm0BMe2u0mXgTfd9pXrmJ07QBI+WAij5P/aPmPMX54HCE1seWL37ZdQ==",
+      "peerDependencies": {
+        "chart.js": "^4.1.1",
+        "vue": "^3.0.0-0 || ^2.7.0"
+      }
+    },
     "node_modules/vue-eslint-parser": {
       "version": "9.4.2",
       "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz",
diff --git a/package.json b/package.json
index 46dfdd1055..dbb57b1624 100644
--- a/package.json
+++ b/package.json
@@ -18,8 +18,12 @@
     "add-asset-webpack-plugin": "2.0.1",
     "ansi_up": "6.0.2",
     "asciinema-player": "3.6.3",
+    "chart.js": "4.3.0",
+    "chartjs-adapter-dayjs-4": "1.0.4",
+    "chartjs-plugin-zoom": "2.0.1",
     "clippie": "4.0.6",
     "css-loader": "6.10.0",
+    "dayjs": "1.11.10",
     "dropzone": "6.0.0-beta.2",
     "easymde": "2.18.0",
     "esbuild-loader": "4.0.3",
@@ -46,6 +50,7 @@
     "uint8-to-base64": "0.2.0",
     "vue": "3.4.18",
     "vue-bar-graph": "2.0.0",
+    "vue-chartjs": "5.3.0",
     "vue-loader": "17.4.2",
     "vue3-calendar-heatmap": "2.0.5",
     "webpack": "5.90.1",
diff --git a/routers/web/repo/activity.go b/routers/web/repo/activity.go
index 3d030edaca..af99c4ed98 100644
--- a/routers/web/repo/activity.go
+++ b/routers/web/repo/activity.go
@@ -22,6 +22,8 @@ func Activity(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.activity")
 	ctx.Data["PageIsActivity"] = true
 
+	ctx.Data["PageIsPulse"] = true
+
 	ctx.Data["Period"] = ctx.Params("period")
 
 	timeUntil := time.Now()
diff --git a/routers/web/repo/contributors.go b/routers/web/repo/contributors.go
new file mode 100644
index 0000000000..f7dedc0b34
--- /dev/null
+++ b/routers/web/repo/contributors.go
@@ -0,0 +1,44 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"errors"
+	"net/http"
+
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/context"
+	contributors_service "code.gitea.io/gitea/services/repository"
+)
+
+const (
+	tplContributors base.TplName = "repo/activity"
+)
+
+// Contributors render the page to show repository contributors graph
+func Contributors(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("repo.contributors")
+
+	ctx.Data["PageIsActivity"] = true
+	ctx.Data["PageIsContributors"] = true
+
+	ctx.PageData["contributionType"] = "commits"
+
+	ctx.PageData["repoLink"] = ctx.Repo.RepoLink
+
+	ctx.HTML(http.StatusOK, tplContributors)
+}
+
+// ContributorsData renders JSON of contributors along with their weekly commit statistics
+func ContributorsData(ctx *context.Context) {
+	if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil {
+		if errors.Is(err, contributors_service.ErrAwaitGeneration) {
+			ctx.Status(http.StatusAccepted)
+			return
+		}
+		ctx.ServerError("GetContributorStats", err)
+	} else {
+		ctx.JSON(http.StatusOK, contributorStats)
+	}
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index caea7bdd1e..cdec6759fd 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1431,6 +1431,10 @@ func registerRoutes(m *web.Route) {
 		m.Group("/activity", func() {
 			m.Get("", repo.Activity)
 			m.Get("/{period}", repo.Activity)
+			m.Group("/contributors", func() {
+				m.Get("", repo.Contributors)
+				m.Get("/data", repo.ContributorsData)
+			})
 		}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypePullRequests, unit.TypeIssues, unit.TypeReleases))
 
 		m.Group("/activity_author_data", func() {
diff --git a/services/repository/contributors_graph.go b/services/repository/contributors_graph.go
new file mode 100644
index 0000000000..8421df8e3a
--- /dev/null
+++ b/services/repository/contributors_graph.go
@@ -0,0 +1,319 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+	"bufio"
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"code.gitea.io/gitea/models/avatars"
+	repo_model "code.gitea.io/gitea/models/repo"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/gitrepo"
+	"code.gitea.io/gitea/modules/graceful"
+	"code.gitea.io/gitea/modules/log"
+	api "code.gitea.io/gitea/modules/structs"
+
+	"gitea.com/go-chi/cache"
+)
+
+const (
+	contributorStatsCacheKey           = "GetContributorStats/%s/%s"
+	contributorStatsCacheTimeout int64 = 60 * 10
+)
+
+var (
+	ErrAwaitGeneration  = errors.New("generation took longer than ")
+	awaitGenerationTime = time.Second * 5
+	generateLock        = sync.Map{}
+)
+
+type WeekData struct {
+	Week      int64 `json:"week"`      // Starting day of the week as Unix timestamp
+	Additions int   `json:"additions"` // Number of additions in that week
+	Deletions int   `json:"deletions"` // Number of deletions in that week
+	Commits   int   `json:"commits"`   // Number of commits in that week
+}
+
+// ContributorData represents statistical git commit count data
+type ContributorData struct {
+	Name         string              `json:"name"`  // Display name of the contributor
+	Login        string              `json:"login"` // Login name of the contributor in case it exists
+	AvatarLink   string              `json:"avatar_link"`
+	HomeLink     string              `json:"home_link"`
+	TotalCommits int64               `json:"total_commits"`
+	Weeks        map[int64]*WeekData `json:"weeks"`
+}
+
+// ExtendedCommitStats contains information for commit stats with author data
+type ExtendedCommitStats struct {
+	Author *api.CommitUser  `json:"author"`
+	Stats  *api.CommitStats `json:"stats"`
+}
+
+const layout = time.DateOnly
+
+func findLastSundayBeforeDate(dateStr string) (string, error) {
+	date, err := time.Parse(layout, dateStr)
+	if err != nil {
+		return "", err
+	}
+
+	weekday := date.Weekday()
+	daysToSubtract := int(weekday) - int(time.Sunday)
+	if daysToSubtract < 0 {
+		daysToSubtract += 7
+	}
+
+	lastSunday := date.AddDate(0, 0, -daysToSubtract)
+	return lastSunday.Format(layout), nil
+}
+
+// GetContributorStats returns contributors stats for git commits for given revision or default branch
+func GetContributorStats(ctx context.Context, cache cache.Cache, repo *repo_model.Repository, revision string) (map[string]*ContributorData, error) {
+	// as GetContributorStats is resource intensive we cache the result
+	cacheKey := fmt.Sprintf(contributorStatsCacheKey, repo.FullName(), revision)
+	if !cache.IsExist(cacheKey) {
+		genReady := make(chan struct{})
+
+		// dont start multible async generations
+		_, run := generateLock.Load(cacheKey)
+		if run {
+			return nil, ErrAwaitGeneration
+		}
+
+		generateLock.Store(cacheKey, struct{}{})
+		// run generation async
+		go generateContributorStats(genReady, cache, cacheKey, repo, revision)
+
+		select {
+		case <-time.After(awaitGenerationTime):
+			return nil, ErrAwaitGeneration
+		case <-genReady:
+			// we got generation ready before timeout
+			break
+		}
+	}
+	// TODO: renew timeout of cache cache.UpdateTimeout(cacheKey, contributorStatsCacheTimeout)
+
+	switch v := cache.Get(cacheKey).(type) {
+	case error:
+		return nil, v
+	case map[string]*ContributorData:
+		return v, nil
+	default:
+		return nil, fmt.Errorf("unexpected type in cache detected")
+	}
+}
+
+// getExtendedCommitStats return the list of *ExtendedCommitStats for the given revision
+func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int */) ([]*ExtendedCommitStats, error) {
+	baseCommit, err := repo.GetCommit(revision)
+	if err != nil {
+		return nil, err
+	}
+	stdoutReader, stdoutWriter, err := os.Pipe()
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		_ = stdoutReader.Close()
+		_ = stdoutWriter.Close()
+	}()
+
+	gitCmd := git.NewCommand(repo.Ctx, "log", "--shortstat", "--no-merges", "--pretty=format:---%n%aN%n%aE%n%as", "--reverse")
+	// AddOptionFormat("--max-count=%d", limit)
+	gitCmd.AddDynamicArguments(baseCommit.ID.String())
+
+	var extendedCommitStats []*ExtendedCommitStats
+	stderr := new(strings.Builder)
+	err = gitCmd.Run(&git.RunOpts{
+		Dir:    repo.Path,
+		Stdout: stdoutWriter,
+		Stderr: stderr,
+		PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+			_ = stdoutWriter.Close()
+			scanner := bufio.NewScanner(stdoutReader)
+			scanner.Split(bufio.ScanLines)
+
+			for scanner.Scan() {
+				line := strings.TrimSpace(scanner.Text())
+				if line != "---" {
+					continue
+				}
+				scanner.Scan()
+				authorName := strings.TrimSpace(scanner.Text())
+				scanner.Scan()
+				authorEmail := strings.TrimSpace(scanner.Text())
+				scanner.Scan()
+				date := strings.TrimSpace(scanner.Text())
+				scanner.Scan()
+				stats := strings.TrimSpace(scanner.Text())
+				if authorName == "" || authorEmail == "" || date == "" || stats == "" {
+					// FIXME: find a better way to parse the output so that we will handle this properly
+					log.Warn("Something is wrong with git log output, skipping...")
+					log.Warn("authorName: %s,  authorEmail: %s,  date: %s,  stats: %s", authorName, authorEmail, date, stats)
+					continue
+				}
+				//  1 file changed, 1 insertion(+), 1 deletion(-)
+				fields := strings.Split(stats, ",")
+
+				commitStats := api.CommitStats{}
+				for _, field := range fields[1:] {
+					parts := strings.Split(strings.TrimSpace(field), " ")
+					value, contributionType := parts[0], parts[1]
+					amount, _ := strconv.Atoi(value)
+
+					if strings.HasPrefix(contributionType, "insertion") {
+						commitStats.Additions = amount
+					} else {
+						commitStats.Deletions = amount
+					}
+				}
+				commitStats.Total = commitStats.Additions + commitStats.Deletions
+				scanner.Scan()
+				scanner.Text() // empty line at the end
+
+				res := &ExtendedCommitStats{
+					Author: &api.CommitUser{
+						Identity: api.Identity{
+							Name:  authorName,
+							Email: authorEmail,
+						},
+						Date: date,
+					},
+					Stats: &commitStats,
+				}
+				extendedCommitStats = append(extendedCommitStats, res)
+
+			}
+			_ = stdoutReader.Close()
+			return nil
+		},
+	})
+	if err != nil {
+		return nil, fmt.Errorf("Failed to get ContributorsCommitStats for repository.\nError: %w\nStderr: %s", err, stderr)
+	}
+
+	return extendedCommitStats, nil
+}
+
+func generateContributorStats(genDone chan struct{}, cache cache.Cache, cacheKey string, repo *repo_model.Repository, revision string) {
+	ctx := graceful.GetManager().HammerContext()
+
+	gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
+	if err != nil {
+		err := fmt.Errorf("OpenRepository: %w", err)
+		_ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
+		return
+	}
+	defer closer.Close()
+
+	if len(revision) == 0 {
+		revision = repo.DefaultBranch
+	}
+	extendedCommitStats, err := getExtendedCommitStats(gitRepo, revision)
+	if err != nil {
+		err := fmt.Errorf("ExtendedCommitStats: %w", err)
+		_ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
+		return
+	}
+	if len(extendedCommitStats) == 0 {
+		err := fmt.Errorf("no commit stats returned for revision '%s'", revision)
+		_ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
+		return
+	}
+
+	layout := time.DateOnly
+
+	unknownUserAvatarLink := user_model.NewGhostUser().AvatarLinkWithSize(ctx, 0)
+	contributorsCommitStats := make(map[string]*ContributorData)
+	contributorsCommitStats["total"] = &ContributorData{
+		Name:  "Total",
+		Weeks: make(map[int64]*WeekData),
+	}
+	total := contributorsCommitStats["total"]
+
+	for _, v := range extendedCommitStats {
+		userEmail := v.Author.Email
+		if len(userEmail) == 0 {
+			continue
+		}
+		u, _ := user_model.GetUserByEmail(ctx, userEmail)
+		if u != nil {
+			// update userEmail with user's primary email address so
+			// that different mail addresses will linked to same account
+			userEmail = u.GetEmail()
+		}
+		// duplicated logic
+		if _, ok := contributorsCommitStats[userEmail]; !ok {
+			if u == nil {
+				avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0)
+				if avatarLink == "" {
+					avatarLink = unknownUserAvatarLink
+				}
+				contributorsCommitStats[userEmail] = &ContributorData{
+					Name:       v.Author.Name,
+					AvatarLink: avatarLink,
+					Weeks:      make(map[int64]*WeekData),
+				}
+			} else {
+				contributorsCommitStats[userEmail] = &ContributorData{
+					Name:       u.DisplayName(),
+					Login:      u.LowerName,
+					AvatarLink: u.AvatarLinkWithSize(ctx, 0),
+					HomeLink:   u.HomeLink(),
+					Weeks:      make(map[int64]*WeekData),
+				}
+			}
+		}
+		// Update user statistics
+		user := contributorsCommitStats[userEmail]
+		startingOfWeek, _ := findLastSundayBeforeDate(v.Author.Date)
+
+		val, _ := time.Parse(layout, startingOfWeek)
+		week := val.UnixMilli()
+
+		if user.Weeks[week] == nil {
+			user.Weeks[week] = &WeekData{
+				Additions: 0,
+				Deletions: 0,
+				Commits:   0,
+				Week:      week,
+			}
+		}
+		if total.Weeks[week] == nil {
+			total.Weeks[week] = &WeekData{
+				Additions: 0,
+				Deletions: 0,
+				Commits:   0,
+				Week:      week,
+			}
+		}
+		user.Weeks[week].Additions += v.Stats.Additions
+		user.Weeks[week].Deletions += v.Stats.Deletions
+		user.Weeks[week].Commits++
+		user.TotalCommits++
+
+		// Update overall statistics
+		total.Weeks[week].Additions += v.Stats.Additions
+		total.Weeks[week].Deletions += v.Stats.Deletions
+		total.Weeks[week].Commits++
+		total.TotalCommits++
+	}
+
+	_ = cache.Put(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout)
+	generateLock.Delete(cacheKey)
+	if genDone != nil {
+		genDone <- struct{}{}
+	}
+}
diff --git a/services/repository/contributors_graph_test.go b/services/repository/contributors_graph_test.go
new file mode 100644
index 0000000000..3801a5eee4
--- /dev/null
+++ b/services/repository/contributors_graph_test.go
@@ -0,0 +1,87 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+	"slices"
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	"code.gitea.io/gitea/modules/git"
+
+	"gitea.com/go-chi/cache"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRepository_ContributorsGraph(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+	assert.NoError(t, repo.LoadOwner(db.DefaultContext))
+	mockCache, err := cache.NewCacher(cache.Options{
+		Adapter:  "memory",
+		Interval: 24 * 60,
+	})
+	assert.NoError(t, err)
+
+	generateContributorStats(nil, mockCache, "key", repo, "404ref")
+	err, isErr := mockCache.Get("key").(error)
+	assert.True(t, isErr)
+	assert.ErrorAs(t, err, &git.ErrNotExist{})
+
+	generateContributorStats(nil, mockCache, "key2", repo, "master")
+	data, isData := mockCache.Get("key2").(map[string]*ContributorData)
+	assert.True(t, isData)
+	var keys []string
+	for k := range data {
+		keys = append(keys, k)
+	}
+	slices.Sort(keys)
+	assert.EqualValues(t, []string{
+		"ethantkoenig@gmail.com",
+		"jimmy.praet@telenet.be",
+		"jon@allspice.io",
+		"total", // generated summary
+	}, keys)
+
+	assert.EqualValues(t, &ContributorData{
+		Name:         "Ethan Koenig",
+		AvatarLink:   "https://secure.gravatar.com/avatar/b42fb195faa8c61b8d88abfefe30e9e3?d=identicon",
+		TotalCommits: 1,
+		Weeks: map[int64]*WeekData{
+			1511654400000: {
+				Week:      1511654400000, // sunday 2017-11-26
+				Additions: 3,
+				Deletions: 0,
+				Commits:   1,
+			},
+		},
+	}, data["ethantkoenig@gmail.com"])
+	assert.EqualValues(t, &ContributorData{
+		Name:         "Total",
+		AvatarLink:   "",
+		TotalCommits: 3,
+		Weeks: map[int64]*WeekData{
+			1511654400000: {
+				Week:      1511654400000, // sunday 2017-11-26 (2017-11-26 20:31:18 -0800)
+				Additions: 3,
+				Deletions: 0,
+				Commits:   1,
+			},
+			1607817600000: {
+				Week:      1607817600000, // sunday 2020-12-13 (2020-12-15 15:23:11 -0500)
+				Additions: 10,
+				Deletions: 0,
+				Commits:   1,
+			},
+			1624752000000: {
+				Week:      1624752000000, // sunday 2021-06-27 (2021-06-29 21:54:09 +0200)
+				Additions: 2,
+				Deletions: 0,
+				Commits:   1,
+			},
+		},
+	}, data["total"])
+}
diff --git a/templates/repo/activity.tmpl b/templates/repo/activity.tmpl
index 3149f20670..960083d2fb 100644
--- a/templates/repo/activity.tmpl
+++ b/templates/repo/activity.tmpl
@@ -1,235 +1,15 @@
 {{template "base/head" .}}
 <div role="main" aria-label="{{.Title}}" class="page-content repository commits">
 	{{template "repo/header" .}}
-	<div class="ui container">
-		<h2 class="ui header activity-header">
-			<span>{{DateTime "long" .DateFrom}} - {{DateTime "long" .DateUntil}}</span>
-			<!-- Period -->
-			<div class="ui floating dropdown jump filter">
-				<div class="ui basic compact button">
-					{{ctx.Locale.Tr "repo.activity.period.filter_label"}} <strong>{{.PeriodText}}</strong>
-					{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-				</div>
-				<div class="menu">
-					<a class="{{if eq .Period "daily"}}active {{end}}item" href="{{$.RepoLink}}/activity/daily">{{ctx.Locale.Tr "repo.activity.period.daily"}}</a>
-					<a class="{{if eq .Period "halfweekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/halfweekly">{{ctx.Locale.Tr "repo.activity.period.halfweekly"}}</a>
-					<a class="{{if eq .Period "weekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/weekly">{{ctx.Locale.Tr "repo.activity.period.weekly"}}</a>
-					<a class="{{if eq .Period "monthly"}}active {{end}}item" href="{{$.RepoLink}}/activity/monthly">{{ctx.Locale.Tr "repo.activity.period.monthly"}}</a>
-					<a class="{{if eq .Period "quarterly"}}active {{end}}item" href="{{$.RepoLink}}/activity/quarterly">{{ctx.Locale.Tr "repo.activity.period.quarterly"}}</a>
-					<a class="{{if eq .Period "semiyearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/semiyearly">{{ctx.Locale.Tr "repo.activity.period.semiyearly"}}</a>
-					<a class="{{if eq .Period "yearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/yearly">{{ctx.Locale.Tr "repo.activity.period.yearly"}}</a>
-				</div>
-			</div>
-		</h2>
-		<div class="divider"></div>
-
-		{{if (or (.Permission.CanRead $.UnitTypeIssues) (.Permission.CanRead $.UnitTypePullRequests))}}
-		<h4 class="ui top attached header">{{ctx.Locale.Tr "repo.activity.overview"}}</h4>
-		<div class="ui attached segment two column grid">
-			{{if .Permission.CanRead $.UnitTypePullRequests}}
-				<div class="column">
-					{{if gt .Activity.ActivePRCount 0}}
-					<div class="stats-table">
-						<a href="#merged-pull-requests" class="table-cell tiny background purple" style="width: {{.Activity.MergedPRPerc}}{{if ne .Activity.MergedPRPerc 0}}%{{end}}"></a>
-						<a href="#proposed-pull-requests" class="table-cell tiny background green"></a>
-					</div>
-					{{else}}
-					<div class="stats-table">
-						<a class="table-cell tiny background light grey"></a>
-					</div>
-					{{end}}
-					{{ctx.Locale.TrN .Activity.ActivePRCount "repo.activity.active_prs_count_1" "repo.activity.active_prs_count_n" .Activity.ActivePRCount | Safe}}
-				</div>
-			{{end}}
-			{{if .Permission.CanRead $.UnitTypeIssues}}
-				<div class="column">
-					{{if gt .Activity.ActiveIssueCount 0}}
-					<div class="stats-table">
-						<a href="#closed-issues" class="table-cell tiny background red" style="width: {{.Activity.ClosedIssuePerc}}{{if ne .Activity.ClosedIssuePerc 0}}%{{end}}"></a>
-						<a href="#new-issues" class="table-cell tiny background green"></a>
-					</div>
-					{{else}}
-					<div class="stats-table">
-						<a class="table-cell tiny background light grey"></a>
-					</div>
-					{{end}}
-					{{ctx.Locale.TrN .Activity.ActiveIssueCount "repo.activity.active_issues_count_1" "repo.activity.active_issues_count_n" .Activity.ActiveIssueCount | Safe}}
-				</div>
-			{{end}}
+	<div class="ui container flex-container">
+		<div class="flex-container-nav">
+			{{template "repo/navbar" .}}
 		</div>
-		<div class="ui attached segment horizontal segments">
-			{{if .Permission.CanRead $.UnitTypePullRequests}}
-				<a href="#merged-pull-requests" class="ui attached segment text center">
-					<span class="text purple">{{svg "octicon-git-pull-request"}}</span> <strong>{{.Activity.MergedPRCount}}</strong><br>
-					{{ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.merged_prs_count_1" "repo.activity.merged_prs_count_n"}}
-				</a>
-				<a href="#proposed-pull-requests" class="ui attached segment text center">
-					<span class="text green">{{svg "octicon-git-branch"}}</span> <strong>{{.Activity.OpenedPRCount}}</strong><br>
-					{{ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.opened_prs_count_1" "repo.activity.opened_prs_count_n"}}
-				</a>
-			{{end}}
-			{{if .Permission.CanRead $.UnitTypeIssues}}
-				<a href="#closed-issues" class="ui attached segment text center">
-					<span class="text red">{{svg "octicon-issue-closed"}}</span> <strong>{{.Activity.ClosedIssueCount}}</strong><br>
-					{{ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.closed_issues_count_1" "repo.activity.closed_issues_count_n"}}
-				</a>
-				<a href="#new-issues" class="ui attached segment text center">
-					<span class="text green">{{svg "octicon-issue-opened"}}</span> <strong>{{.Activity.OpenedIssueCount}}</strong><br>
-					{{ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.new_issues_count_1" "repo.activity.new_issues_count_n"}}
-				</a>
-			{{end}}
+		<div class="flex-container-main">
+			{{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}}
+			{{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}}
 		</div>
-		{{end}}
-
-		{{if .Permission.CanRead $.UnitTypeCode}}
-			{{if eq .Activity.Code.CommitCountInAllBranches 0}}
-				<div class="ui center aligned segment">
-				<h4 class="ui header">{{ctx.Locale.Tr "repo.activity.no_git_activity"}}</h4>
-				</div>
-			{{end}}
-			{{if gt .Activity.Code.CommitCountInAllBranches 0}}
-				<div class="ui attached segment horizontal segments">
-					<div class="ui attached segment text">
-						{{ctx.Locale.Tr "repo.activity.git_stats_exclude_merges"}}
-						<strong>{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n" .Activity.Code.AuthorCount}}</strong>
-						{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_pushed_1" "repo.activity.git_stats_pushed_n"}}
-						<strong>{{ctx.Locale.TrN .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCount}}</strong>
-						{{ctx.Locale.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch}}
-						<strong>{{ctx.Locale.TrN .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCountInAllBranches}}</strong>
-						{{ctx.Locale.Tr "repo.activity.git_stats_push_to_all_branches"}}
-						{{ctx.Locale.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch}}
-						<strong>{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n" .Activity.Code.ChangedFiles}}</strong>
-						{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_files_changed_1" "repo.activity.git_stats_files_changed_n"}}
-						{{ctx.Locale.Tr "repo.activity.git_stats_additions"}}
-						<strong class="text green">{{ctx.Locale.TrN .Activity.Code.Additions "repo.activity.git_stats_addition_1" "repo.activity.git_stats_addition_n" .Activity.Code.Additions}}</strong>
-						{{ctx.Locale.Tr "repo.activity.git_stats_and_deletions"}}
-						<strong class="text red">{{ctx.Locale.TrN .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n" .Activity.Code.Deletions}}</strong>.
-					</div>
-					<div class="ui attached segment">
-						<div id="repo-activity-top-authors-chart"></div>
-					</div>
-				</div>
-			{{end}}
-		{{end}}
-
-		{{if gt .Activity.PublishedReleaseCount 0}}
-			<h4 class="divider divider-text gt-normal-case" id="published-releases">
-				{{svg "octicon-tag" 16 "gt-mr-3"}}
-				{{ctx.Locale.Tr "repo.activity.title.releases_published_by"
-					(ctx.Locale.TrN .Activity.PublishedReleaseCount "repo.activity.title.releases_1" "repo.activity.title.releases_n" .Activity.PublishedReleaseCount)
-					(ctx.Locale.TrN .Activity.PublishedReleaseAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.PublishedReleaseAuthorCount)
-				}}
-			</h4>
-			<div class="list">
-				{{range .Activity.PublishedReleases}}
-					<p class="desc">
-						<span class="ui green label">{{ctx.Locale.Tr "repo.activity.published_release_label"}}</span>
-						{{.TagName}}
-						{{if not .IsTag}}
-							<a class="title" href="{{$.RepoLink}}/src/{{.TagName | PathEscapeSegments}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
-						{{end}}
-						{{TimeSinceUnix .CreatedUnix ctx.Locale}}
-					</p>
-				{{end}}
-			</div>
-		{{end}}
-
-		{{if gt .Activity.MergedPRCount 0}}
-			<h4 class="divider divider-text gt-normal-case" id="merged-pull-requests">
-				{{svg "octicon-git-pull-request" 16 "gt-mr-3"}}
-				{{ctx.Locale.Tr "repo.activity.title.prs_merged_by"
-					(ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.MergedPRCount)
-					(ctx.Locale.TrN .Activity.MergedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.MergedPRAuthorCount)
-				}}
-			</h4>
-			<div class="list">
-				{{range .Activity.MergedPRs}}
-					<p class="desc">
-						<span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span>
-						#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
-						{{TimeSinceUnix .MergedUnix ctx.Locale}}
-					</p>
-				{{end}}
-			</div>
-		{{end}}
-
-		{{if gt .Activity.OpenedPRCount 0}}
-			<h4 class="divider divider-text gt-normal-case" id="proposed-pull-requests">
-				{{svg "octicon-git-branch" 16 "gt-mr-3"}}
-				{{ctx.Locale.Tr "repo.activity.title.prs_opened_by"
-					(ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.OpenedPRCount)
-					(ctx.Locale.TrN .Activity.OpenedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedPRAuthorCount)
-				}}
-			</h4>
-			<div class="list">
-				{{range .Activity.OpenedPRs}}
-					<p class="desc">
-						<span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span>
-						#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
-						{{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}}
-					</p>
-				{{end}}
-			</div>
-		{{end}}
-
-		{{if gt .Activity.ClosedIssueCount 0}}
-			<h4 class="divider divider-text gt-normal-case" id="closed-issues">
-				{{svg "octicon-issue-closed" 16 "gt-mr-3"}}
-				{{ctx.Locale.Tr "repo.activity.title.issues_closed_from"
-					(ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.ClosedIssueCount)
-					(ctx.Locale.TrN .Activity.ClosedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.ClosedIssueAuthorCount)
-				}}
-			</h4>
-			<div class="list">
-				{{range .Activity.ClosedIssues}}
-					<p class="desc">
-						<span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span>
-						#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
-						{{TimeSinceUnix .ClosedUnix ctx.Locale}}
-					</p>
-				{{end}}
-			</div>
-		{{end}}
-
-		{{if gt .Activity.OpenedIssueCount 0}}
-			<h4 class="divider divider-text gt-normal-case" id="new-issues">
-				{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
-				{{ctx.Locale.Tr "repo.activity.title.issues_created_by"
-					(ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.OpenedIssueCount)
-					(ctx.Locale.TrN .Activity.OpenedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedIssueAuthorCount)
-				}}
-			</h4>
-			<div class="list">
-				{{range .Activity.OpenedIssues}}
-					<p class="desc">
-						<span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span>
-						#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
-						{{TimeSinceUnix .CreatedUnix ctx.Locale}}
-					</p>
-				{{end}}
-			</div>
-		{{end}}
-
-		{{if gt .Activity.UnresolvedIssueCount 0}}
-			<h4 class="divider divider-text gt-normal-case" id="unresolved-conversations" data-tooltip-content="{{ctx.Locale.Tr "repo.activity.unresolved_conv_desc"}}">
-				{{svg "octicon-comment-discussion" 16 "gt-mr-3"}}
-				{{ctx.Locale.TrN .Activity.UnresolvedIssueCount "repo.activity.title.unresolved_conv_1" "repo.activity.title.unresolved_conv_n" .Activity.UnresolvedIssueCount}}
-			</h4>
-			<div class="list">
-				{{range .Activity.UnresolvedIssues}}
-					<p class="desc">
-						<span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span>
-						#{{.Index}}
-						{{if .IsPull}}
-						<a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
-						{{else}}
-						<a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
-						{{end}}
-						{{TimeSinceUnix .UpdatedUnix ctx.Locale}}
-					</p>
-				{{end}}
-			</div>
-		{{end}}
 	</div>
 </div>
 {{template "base/footer" .}}
+
diff --git a/templates/repo/contributors.tmpl b/templates/repo/contributors.tmpl
new file mode 100644
index 0000000000..49a251c1f9
--- /dev/null
+++ b/templates/repo/contributors.tmpl
@@ -0,0 +1,13 @@
+{{if .Permission.CanRead $.UnitTypeCode}}
+	<div id="repo-contributors-chart"
+		data-locale-filter-label="{{ctx.Locale.Tr "repo.contributors.contribution_type.filter_label"}}"
+		data-locale-contribution-type-commits="{{ctx.Locale.Tr "repo.contributors.contribution_type.commits"}}"
+		data-locale-contribution-type-additions="{{ctx.Locale.Tr "repo.contributors.contribution_type.additions"}}"
+		data-locale-contribution-type-deletions="{{ctx.Locale.Tr "repo.contributors.contribution_type.deletions"}}"
+		data-locale-loading-title="{{ctx.Locale.Tr "repo.contributors.loading_title"}}"
+		data-locale-loading-title-failed="{{ctx.Locale.Tr "repo.contributors.loading_title_failed"}}"
+		data-locale-loading-info="{{ctx.Locale.Tr "repo.contributors.loading_info"}}"
+		data-locale-component-failed-to-load="{{ctx.Locale.Tr "repo.contributors.component_failed_to_load"}}"
+	>
+	</div>
+{{end}}
diff --git a/templates/repo/navbar.tmpl b/templates/repo/navbar.tmpl
new file mode 100644
index 0000000000..a9042ee30d
--- /dev/null
+++ b/templates/repo/navbar.tmpl
@@ -0,0 +1,8 @@
+<div class="ui fluid vertical menu">
+	<a class="{{if .PageIsPulse}}active {{end}}item" href="{{.RepoLink}}/activity">
+		{{ctx.Locale.Tr "repo.activity.navbar.pulse"}}
+	</a>
+	<a class="{{if .PageIsContributors}}active {{end}}item" href="{{.RepoLink}}/activity/contributors">
+		{{ctx.Locale.Tr "repo.activity.navbar.contributors"}}
+	</a>
+</div>
diff --git a/templates/repo/pulse.tmpl b/templates/repo/pulse.tmpl
new file mode 100644
index 0000000000..ccd7ebf6b5
--- /dev/null
+++ b/templates/repo/pulse.tmpl
@@ -0,0 +1,227 @@
+<h2 class="ui header activity-header">
+	<span>{{DateTime "long" .DateFrom}} - {{DateTime "long" .DateUntil}}</span>
+	<!-- Period -->
+	<div class="ui floating dropdown jump filter">
+		<div class="ui basic compact button">
+			{{ctx.Locale.Tr "repo.activity.period.filter_label"}} <strong>{{.PeriodText}}</strong>
+			{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+		</div>
+		<div class="menu">
+			<a class="{{if eq .Period "daily"}}active {{end}}item" href="{{$.RepoLink}}/activity/daily">{{ctx.Locale.Tr "repo.activity.period.daily"}}</a>
+			<a class="{{if eq .Period "halfweekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/halfweekly">{{ctx.Locale.Tr "repo.activity.period.halfweekly"}}</a>
+			<a class="{{if eq .Period "weekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/weekly">{{ctx.Locale.Tr "repo.activity.period.weekly"}}</a>
+			<a class="{{if eq .Period "monthly"}}active {{end}}item" href="{{$.RepoLink}}/activity/monthly">{{ctx.Locale.Tr "repo.activity.period.monthly"}}</a>
+			<a class="{{if eq .Period "quarterly"}}active {{end}}item" href="{{$.RepoLink}}/activity/quarterly">{{ctx.Locale.Tr "repo.activity.period.quarterly"}}</a>
+			<a class="{{if eq .Period "semiyearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/semiyearly">{{ctx.Locale.Tr "repo.activity.period.semiyearly"}}</a>
+			<a class="{{if eq .Period "yearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/yearly">{{ctx.Locale.Tr "repo.activity.period.yearly"}}</a>
+		</div>
+	</div>
+</h2>
+
+{{if (or (.Permission.CanRead $.UnitTypeIssues) (.Permission.CanRead $.UnitTypePullRequests))}}
+<h4 class="ui top attached header">{{ctx.Locale.Tr "repo.activity.overview"}}</h4>
+<div class="ui attached segment two column grid">
+	{{if .Permission.CanRead $.UnitTypePullRequests}}
+		<div class="column">
+			{{if gt .Activity.ActivePRCount 0}}
+			<div class="stats-table">
+				<a href="#merged-pull-requests" class="table-cell tiny background purple" style="width: {{.Activity.MergedPRPerc}}{{if ne .Activity.MergedPRPerc 0}}%{{end}}"></a>
+				<a href="#proposed-pull-requests" class="table-cell tiny background green"></a>
+			</div>
+			{{else}}
+			<div class="stats-table">
+				<a class="table-cell tiny background light grey"></a>
+			</div>
+			{{end}}
+			{{ctx.Locale.TrN .Activity.ActivePRCount "repo.activity.active_prs_count_1" "repo.activity.active_prs_count_n" .Activity.ActivePRCount | Safe}}
+		</div>
+	{{end}}
+	{{if .Permission.CanRead $.UnitTypeIssues}}
+		<div class="column">
+			{{if gt .Activity.ActiveIssueCount 0}}
+			<div class="stats-table">
+				<a href="#closed-issues" class="table-cell tiny background red" style="width: {{.Activity.ClosedIssuePerc}}{{if ne .Activity.ClosedIssuePerc 0}}%{{end}}"></a>
+				<a href="#new-issues" class="table-cell tiny background green"></a>
+			</div>
+			{{else}}
+			<div class="stats-table">
+				<a class="table-cell tiny background light grey"></a>
+			</div>
+			{{end}}
+			{{ctx.Locale.TrN .Activity.ActiveIssueCount "repo.activity.active_issues_count_1" "repo.activity.active_issues_count_n" .Activity.ActiveIssueCount | Safe}}
+		</div>
+	{{end}}
+</div>
+<div class="ui attached segment horizontal segments">
+	{{if .Permission.CanRead $.UnitTypePullRequests}}
+		<a href="#merged-pull-requests" class="ui attached segment text center">
+			<span class="text purple">{{svg "octicon-git-pull-request"}}</span> <strong>{{.Activity.MergedPRCount}}</strong><br>
+			{{ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.merged_prs_count_1" "repo.activity.merged_prs_count_n"}}
+		</a>
+		<a href="#proposed-pull-requests" class="ui attached segment text center">
+			<span class="text green">{{svg "octicon-git-branch"}}</span> <strong>{{.Activity.OpenedPRCount}}</strong><br>
+			{{ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.opened_prs_count_1" "repo.activity.opened_prs_count_n"}}
+		</a>
+	{{end}}
+	{{if .Permission.CanRead $.UnitTypeIssues}}
+		<a href="#closed-issues" class="ui attached segment text center">
+			<span class="text red">{{svg "octicon-issue-closed"}}</span> <strong>{{.Activity.ClosedIssueCount}}</strong><br>
+			{{ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.closed_issues_count_1" "repo.activity.closed_issues_count_n"}}
+		</a>
+		<a href="#new-issues" class="ui attached segment text center">
+			<span class="text green">{{svg "octicon-issue-opened"}}</span> <strong>{{.Activity.OpenedIssueCount}}</strong><br>
+			{{ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.new_issues_count_1" "repo.activity.new_issues_count_n"}}
+		</a>
+	{{end}}
+</div>
+{{end}}
+
+{{if .Permission.CanRead $.UnitTypeCode}}
+	{{if eq .Activity.Code.CommitCountInAllBranches 0}}
+		<div class="ui center aligned segment">
+		<h4 class="ui header">{{ctx.Locale.Tr "repo.activity.no_git_activity"}}</h4>
+		</div>
+	{{end}}
+	{{if gt .Activity.Code.CommitCountInAllBranches 0}}
+		<div class="ui attached segment horizontal segments">
+			<div class="ui attached segment text">
+				{{ctx.Locale.Tr "repo.activity.git_stats_exclude_merges"}}
+				<strong>{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n" .Activity.Code.AuthorCount}}</strong>
+				{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_pushed_1" "repo.activity.git_stats_pushed_n"}}
+				<strong>{{ctx.Locale.TrN .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCount}}</strong>
+				{{ctx.Locale.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch}}
+				<strong>{{ctx.Locale.TrN .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCountInAllBranches}}</strong>
+				{{ctx.Locale.Tr "repo.activity.git_stats_push_to_all_branches"}}
+				{{ctx.Locale.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch}}
+				<strong>{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n" .Activity.Code.ChangedFiles}}</strong>
+				{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_files_changed_1" "repo.activity.git_stats_files_changed_n"}}
+				{{ctx.Locale.Tr "repo.activity.git_stats_additions"}}
+				<strong class="text green">{{ctx.Locale.TrN .Activity.Code.Additions "repo.activity.git_stats_addition_1" "repo.activity.git_stats_addition_n" .Activity.Code.Additions}}</strong>
+				{{ctx.Locale.Tr "repo.activity.git_stats_and_deletions"}}
+				<strong class="text red">{{ctx.Locale.TrN .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n" .Activity.Code.Deletions}}</strong>.
+			</div>
+			<div class="ui attached segment">
+				<div id="repo-activity-top-authors-chart"></div>
+			</div>
+		</div>
+	{{end}}
+{{end}}
+
+{{if gt .Activity.PublishedReleaseCount 0}}
+	<h4 class="divider divider-text gt-normal-case" id="published-releases">
+		{{svg "octicon-tag" 16 "gt-mr-3"}}
+		{{ctx.Locale.Tr "repo.activity.title.releases_published_by"
+			(ctx.Locale.TrN .Activity.PublishedReleaseCount "repo.activity.title.releases_1" "repo.activity.title.releases_n" .Activity.PublishedReleaseCount)
+			(ctx.Locale.TrN .Activity.PublishedReleaseAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.PublishedReleaseAuthorCount)
+		}}
+	</h4>
+	<div class="list">
+		{{range .Activity.PublishedReleases}}
+			<p class="desc">
+				<span class="ui green label">{{ctx.Locale.Tr "repo.activity.published_release_label"}}</span>
+				{{.TagName}}
+				{{if not .IsTag}}
+					<a class="title" href="{{$.RepoLink}}/src/{{.TagName | PathEscapeSegments}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				{{end}}
+				{{TimeSinceUnix .CreatedUnix ctx.Locale}}
+			</p>
+		{{end}}
+	</div>
+{{end}}
+
+{{if gt .Activity.MergedPRCount 0}}
+	<h4 class="divider divider-text gt-normal-case" id="merged-pull-requests">
+		{{svg "octicon-git-pull-request" 16 "gt-mr-3"}}
+		{{ctx.Locale.Tr "repo.activity.title.prs_merged_by"
+			(ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.MergedPRCount)
+			(ctx.Locale.TrN .Activity.MergedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.MergedPRAuthorCount)
+		}}
+	</h4>
+	<div class="list">
+		{{range .Activity.MergedPRs}}
+			<p class="desc">
+				<span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span>
+				#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				{{TimeSinceUnix .MergedUnix ctx.Locale}}
+			</p>
+		{{end}}
+	</div>
+{{end}}
+
+{{if gt .Activity.OpenedPRCount 0}}
+	<h4 class="divider divider-text gt-normal-case" id="proposed-pull-requests">
+		{{svg "octicon-git-branch" 16 "gt-mr-3"}}
+		{{ctx.Locale.Tr "repo.activity.title.prs_opened_by"
+			(ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.OpenedPRCount)
+			(ctx.Locale.TrN .Activity.OpenedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedPRAuthorCount)
+		}}
+	</h4>
+	<div class="list">
+		{{range .Activity.OpenedPRs}}
+			<p class="desc">
+				<span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span>
+				#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				{{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}}
+			</p>
+		{{end}}
+	</div>
+{{end}}
+
+{{if gt .Activity.ClosedIssueCount 0}}
+	<h4 class="divider divider-text gt-normal-case" id="closed-issues">
+		{{svg "octicon-issue-closed" 16 "gt-mr-3"}}
+		{{ctx.Locale.Tr "repo.activity.title.issues_closed_from"
+			(ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.ClosedIssueCount)
+			(ctx.Locale.TrN .Activity.ClosedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.ClosedIssueAuthorCount)
+		}}
+	</h4>
+	<div class="list">
+		{{range .Activity.ClosedIssues}}
+			<p class="desc">
+				<span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span>
+				#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				{{TimeSinceUnix .ClosedUnix ctx.Locale}}
+			</p>
+		{{end}}
+	</div>
+{{end}}
+
+{{if gt .Activity.OpenedIssueCount 0}}
+	<h4 class="divider divider-text gt-normal-case" id="new-issues">
+		{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
+		{{ctx.Locale.Tr "repo.activity.title.issues_created_by"
+			(ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.OpenedIssueCount)
+			(ctx.Locale.TrN .Activity.OpenedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedIssueAuthorCount)
+		}}
+	</h4>
+	<div class="list">
+		{{range .Activity.OpenedIssues}}
+			<p class="desc">
+				<span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span>
+				#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				{{TimeSinceUnix .CreatedUnix ctx.Locale}}
+			</p>
+		{{end}}
+	</div>
+{{end}}
+
+{{if gt .Activity.UnresolvedIssueCount 0}}
+	<h4 class="divider divider-text gt-normal-case" id="unresolved-conversations" data-tooltip-content="{{ctx.Locale.Tr "repo.activity.unresolved_conv_desc"}}">
+		{{svg "octicon-comment-discussion" 16 "gt-mr-3"}}
+		{{ctx.Locale.TrN .Activity.UnresolvedIssueCount "repo.activity.title.unresolved_conv_1" "repo.activity.title.unresolved_conv_n" .Activity.UnresolvedIssueCount}}
+	</h4>
+	<div class="list">
+		{{range .Activity.UnresolvedIssues}}
+			<p class="desc">
+				<span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span>
+				#{{.Index}}
+				{{if .IsPull}}
+				<a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				{{else}}
+				<a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				{{end}}
+				{{TimeSinceUnix .UpdatedUnix ctx.Locale}}
+			</p>
+		{{end}}
+	</div>
+{{end}}
diff --git a/web_src/js/components/.eslintrc.yaml b/web_src/js/components/.eslintrc.yaml
index 0cab470f6b..0d233442bc 100644
--- a/web_src/js/components/.eslintrc.yaml
+++ b/web_src/js/components/.eslintrc.yaml
@@ -7,6 +7,10 @@ extends:
   - plugin:vue/vue3-recommended
   - plugin:vue-scoped-css/vue3-recommended
 
+parserOptions:
+  sourceType: module
+  ecmaVersion: latest
+
 env:
   browser: true
 
diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue
new file mode 100644
index 0000000000..fa1545b3df
--- /dev/null
+++ b/web_src/js/components/RepoContributors.vue
@@ -0,0 +1,443 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import {
+  Chart,
+  Title,
+  Tooltip,
+  Legend,
+  BarElement,
+  CategoryScale,
+  LinearScale,
+  TimeScale,
+  PointElement,
+  LineElement,
+  Filler,
+} from 'chart.js';
+import {GET} from '../modules/fetch.js';
+import zoomPlugin from 'chartjs-plugin-zoom';
+import {Line as ChartLine} from 'vue-chartjs';
+import {
+  startDaysBetween,
+  firstStartDateAfterDate,
+  fillEmptyStartDaysWithZeroes,
+} from '../utils/time.js';
+import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
+import $ from 'jquery';
+
+const {pageData} = window.config;
+
+const colors = {
+  text: '--color-text',
+  border: '--color-secondary-alpha-60',
+  commits: '--color-primary-alpha-60',
+  additions: '--color-green',
+  deletions: '--color-red',
+  title: '--color-secondary-dark-4',
+};
+
+const styles = window.getComputedStyle(document.documentElement);
+const getColor = (name) => styles.getPropertyValue(name).trim();
+
+for (const [key, value] of Object.entries(colors)) {
+  colors[key] = getColor(value);
+}
+
+const customEventListener = {
+  id: 'customEventListener',
+  afterEvent: (chart, args, opts) => {
+    // event will be replayed from chart.update when reset zoom,
+    // so we need to check whether args.replay is true to avoid call loops
+    if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
+      chart.resetZoom();
+      opts.instance.updateOtherCharts(args.event, true);
+    }
+  }
+};
+
+Chart.defaults.color = colors.text;
+Chart.defaults.borderColor = colors.border;
+
+Chart.register(
+  TimeScale,
+  CategoryScale,
+  LinearScale,
+  BarElement,
+  Title,
+  Tooltip,
+  Legend,
+  PointElement,
+  LineElement,
+  Filler,
+  zoomPlugin,
+  customEventListener,
+);
+
+export default {
+  components: {ChartLine, SvgIcon},
+  props: {
+    locale: {
+      type: Object,
+      required: true,
+    },
+  },
+  data: () => ({
+    isLoading: false,
+    errorText: '',
+    totalStats: {},
+    sortedContributors: {},
+    repoLink: pageData.repoLink || [],
+    type: pageData.contributionType,
+    contributorsStats: [],
+    xAxisStart: null,
+    xAxisEnd: null,
+    xAxisMin: null,
+    xAxisMax: null,
+  }),
+  mounted() {
+    this.fetchGraphData();
+
+    $('#repo-contributors').dropdown({
+      onChange: (val) => {
+        this.xAxisMin = this.xAxisStart;
+        this.xAxisMax = this.xAxisEnd;
+        this.type = val;
+        this.sortContributors();
+      }
+    });
+  },
+  methods: {
+    sortContributors() {
+      const contributors = this.filterContributorWeeksByDateRange();
+      const criteria = `total_${this.type}`;
+      this.sortedContributors = Object.values(contributors)
+        .filter((contributor) => contributor[criteria] !== 0)
+        .sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1)
+        .slice(0, 100);
+    },
+
+    async fetchGraphData() {
+      this.isLoading = true;
+      try {
+        let response;
+        do {
+          response = await GET(`${this.repoLink}/activity/contributors/data`);
+          if (response.status === 202) {
+            await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for 1 second before retrying
+          }
+        } while (response.status === 202);
+        if (response.ok) {
+          const data = await response.json();
+          const {total, ...rest} = data;
+          // below line might be deleted if we are sure go produces map always sorted by keys
+          total.weeks = Object.fromEntries(Object.entries(total.weeks).sort());
+
+          const weekValues = Object.values(total.weeks);
+          this.xAxisStart = weekValues[0].week;
+          this.xAxisEnd = firstStartDateAfterDate(new Date());
+          const startDays = startDaysBetween(new Date(this.xAxisStart), new Date(this.xAxisEnd));
+          total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
+          this.xAxisMin = this.xAxisStart;
+          this.xAxisMax = this.xAxisEnd;
+          this.contributorsStats = {};
+          for (const [email, user] of Object.entries(rest)) {
+            user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks);
+            this.contributorsStats[email] = user;
+          }
+          this.sortContributors();
+          this.totalStats = total;
+          this.errorText = '';
+        } else {
+          this.errorText = response.statusText;
+        }
+      } catch (err) {
+        this.errorText = err.message;
+      } finally {
+        this.isLoading = false;
+      }
+    },
+
+    filterContributorWeeksByDateRange() {
+      const filteredData = {};
+      const data = this.contributorsStats;
+      for (const key of Object.keys(data)) {
+        const user = data[key];
+        user.total_commits = 0;
+        user.total_additions = 0;
+        user.total_deletions = 0;
+        user.max_contribution_type = 0;
+        const filteredWeeks = user.weeks.filter((week) => {
+          const oneWeek = 7 * 24 * 60 * 60 * 1000;
+          if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) {
+            user.total_commits += week.commits;
+            user.total_additions += week.additions;
+            user.total_deletions += week.deletions;
+            if (week[this.type] > user.max_contribution_type) {
+              user.max_contribution_type = week[this.type];
+            }
+            return true;
+          }
+          return false;
+        });
+        // this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
+        // for details.
+        user.max_contribution_type += 1;
+
+        filteredData[key] = {...user, weeks: filteredWeeks};
+      }
+
+      return filteredData;
+    },
+
+    maxMainGraph() {
+      // This method calculates maximum value for Y value of the main graph. If the number
+      // of maximum contributions for selected contribution type is 15.955 it is probably
+      // better to round it up to 20.000.This method is responsible for doing that.
+      // Normally, chartjs handles this automatically, but it will resize the graph when you
+      // zoom, pan etc. I think resizing the graph makes it harder to compare things visually.
+      const maxValue = Math.max(
+        ...this.totalStats.weeks.map((o) => o[this.type])
+      );
+      const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
+      if (coefficient % 1 === 0) return maxValue;
+      return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
+    },
+
+    maxContributorGraph() {
+      // Similar to maxMainGraph method this method calculates maximum value for Y value
+      // for contributors' graph. If I let chartjs do this for me, it will choose different
+      // maxY value for each contributors' graph which again makes it harder to compare.
+      const maxValue = Math.max(
+        ...this.sortedContributors.map((c) => c.max_contribution_type)
+      );
+      const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
+      if (coefficient % 1 === 0) return maxValue;
+      return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
+    },
+
+    toGraphData(data) {
+      return {
+        datasets: [
+          {
+            data: data.map((i) => ({x: i.week, y: i[this.type]})),
+            pointRadius: 0,
+            pointHitRadius: 0,
+            fill: 'start',
+            backgroundColor: colors[this.type],
+            borderWidth: 0,
+            tension: 0.3,
+          },
+        ],
+      };
+    },
+
+    updateOtherCharts(event, reset) {
+      const minVal = event.chart.options.scales.x.min;
+      const maxVal = event.chart.options.scales.x.max;
+      if (reset) {
+        this.xAxisMin = this.xAxisStart;
+        this.xAxisMax = this.xAxisEnd;
+        this.sortContributors();
+      } else if (minVal) {
+        this.xAxisMin = minVal;
+        this.xAxisMax = maxVal;
+        this.sortContributors();
+      }
+    },
+
+    getOptions(type) {
+      return {
+        responsive: true,
+        maintainAspectRatio: false,
+        animation: false,
+        events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
+        plugins: {
+          title: {
+            display: type === 'main',
+            text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
+            color: colors.title,
+            position: 'top',
+            align: 'center',
+          },
+          customEventListener: {
+            chartType: type,
+            instance: this,
+          },
+          legend: {
+            display: false,
+          },
+          zoom: {
+            pan: {
+              enabled: true,
+              modifierKey: 'shift',
+              mode: 'x',
+              threshold: 20,
+              onPanComplete: this.updateOtherCharts,
+            },
+            limits: {
+              x: {
+                // Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits
+                // to know what each option means
+                min: 'original',
+                max: 'original',
+
+                // number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph
+                minRange: 2 * 7 * 24 * 60 * 60 * 1000,
+              },
+            },
+            zoom: {
+              drag: {
+                enabled: type === 'main',
+              },
+              pinch: {
+                enabled: type === 'main',
+              },
+              mode: 'x',
+              onZoomComplete: this.updateOtherCharts,
+            },
+          },
+        },
+        scales: {
+          x: {
+            min: this.xAxisMin,
+            max: this.xAxisMax,
+            type: 'time',
+            grid: {
+              display: false,
+            },
+            time: {
+              minUnit: 'month',
+            },
+            ticks: {
+              maxRotation: 0,
+              maxTicksLimit: type === 'main' ? 12 : 6,
+            },
+          },
+          y: {
+            min: 0,
+            max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(),
+            ticks: {
+              maxTicksLimit: type === 'main' ? 6 : 4,
+            },
+          },
+        },
+      };
+    },
+  },
+};
+</script>
+<template>
+  <div>
+    <h2 class="ui header gt-df gt-ac gt-sb">
+      <div>
+        <relative-time
+          v-if="xAxisMin > 0"
+          format="datetime"
+          year="numeric"
+          month="short"
+          day="numeric"
+          weekday=""
+          :datetime="new Date(xAxisMin)"
+        >
+          {{ new Date(xAxisMin) }}
+        </relative-time>
+        {{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
+        <relative-time
+          v-if="xAxisMax > 0"
+          format="datetime"
+          year="numeric"
+          month="short"
+          day="numeric"
+          weekday=""
+          :datetime="new Date(xAxisMax)"
+        >
+          {{ new Date(xAxisMax) }}
+        </relative-time>
+      </div>
+      <div>
+        <!-- Contribution type -->
+        <div class="ui dropdown jump" id="repo-contributors">
+          <div class="ui basic compact button">
+            <span class="text">
+              {{ locale.filterLabel }} <strong>{{ locale.contributionType[type] }}</strong>
+              <svg-icon name="octicon-triangle-down" :size="14"/>
+            </span>
+          </div>
+          <div class="menu">
+            <div :class="['item', {'active': type === 'commits'}]">
+              {{ locale.contributionType.commits }}
+            </div>
+            <div :class="['item', {'active': type === 'additions'}]">
+              {{ locale.contributionType.additions }}
+            </div>
+            <div :class="['item', {'active': type === 'deletions'}]">
+              {{ locale.contributionType.deletions }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </h2>
+    <div class="gt-df ui segment main-graph">
+      <div v-if="isLoading || errorText !== ''" class="gt-tc gt-m-auto">
+        <div v-if="isLoading">
+          <SvgIcon name="octicon-sync" class="gt-mr-3 job-status-rotate"/>
+          {{ locale.loadingInfo }}
+        </div>
+        <div v-else class="text red">
+          <SvgIcon name="octicon-x-circle-fill"/>
+          {{ errorText }}
+        </div>
+      </div>
+      <ChartLine
+        v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0"
+        :data="toGraphData(totalStats.weeks)" :options="getOptions('main')"
+      />
+    </div>
+    <div class="contributor-grid">
+      <div
+        v-for="(contributor, index) in sortedContributors" :key="index" class="stats-table"
+        v-memo="[sortedContributors, type]"
+      >
+        <div class="ui top attached header gt-df gt-f1">
+          <b class="ui right">#{{ index + 1 }}</b>
+          <a :href="contributor.home_link">
+            <img class="ui avatar gt-vm" height="40" width="40" :src="contributor.avatar_link">
+          </a>
+          <div class="gt-ml-3">
+            <a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a>
+            <h4 v-else class="contributor-name">
+              {{ contributor.name }}
+            </h4>
+            <p class="gt-font-12 gt-df gt-gap-2">
+              <strong v-if="contributor.total_commits">{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}</strong>
+              <strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
+              <strong v-if="contributor.total_deletions" class="text red">
+                {{ contributor.total_deletions.toLocaleString() }}--</strong>
+            </p>
+          </div>
+        </div>
+        <div class="ui attached segment">
+          <div>
+            <ChartLine
+              :data="toGraphData(contributor.weeks)"
+              :options="getOptions('contributor')"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<style scoped>
+.main-graph {
+  height: 260px;
+}
+.contributor-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 1rem;
+}
+
+.contributor-name {
+  margin-bottom: 0;
+}
+</style>
diff --git a/web_src/js/features/contributors.js b/web_src/js/features/contributors.js
new file mode 100644
index 0000000000..66185ac315
--- /dev/null
+++ b/web_src/js/features/contributors.js
@@ -0,0 +1,28 @@
+import {createApp} from 'vue';
+
+export async function initRepoContributors() {
+  const el = document.getElementById('repo-contributors-chart');
+  if (!el) return;
+
+  const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue');
+  try {
+    const View = createApp(RepoContributors, {
+      locale: {
+        filterLabel: el.getAttribute('data-locale-filter-label'),
+        contributionType: {
+          commits: el.getAttribute('data-locale-contribution-type-commits'),
+          additions: el.getAttribute('data-locale-contribution-type-additions'),
+          deletions: el.getAttribute('data-locale-contribution-type-deletions'),
+        },
+
+        loadingTitle: el.getAttribute('data-locale-loading-title'),
+        loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
+        loadingInfo: el.getAttribute('data-locale-loading-info'),
+      }
+    });
+    View.mount(el);
+  } catch (err) {
+    console.error('RepoContributors failed to load', err);
+    el.textContent = el.getAttribute('data-locale-component-failed-to-load');
+  }
+}
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 4713618506..078f9fc9df 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -83,6 +83,7 @@ import {initGiteaFomantic} from './modules/fomantic.js';
 import {onDomReady} from './utils/dom.js';
 import {initRepoIssueList} from './features/repo-issue-list.js';
 import {initCommonIssueListQuickGoto} from './features/common-issue-list.js';
+import {initRepoContributors} from './features/contributors.js';
 import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js';
 import {initDirAuto} from './modules/dirauto.js';
 
@@ -172,6 +173,7 @@ onDomReady(() => {
   initRepoWikiForm();
   initRepository();
   initRepositoryActionView();
+  initRepoContributors();
 
   initCommitStatuses();
   initCaptcha();
diff --git a/web_src/js/utils/time.js b/web_src/js/utils/time.js
new file mode 100644
index 0000000000..3284e893e1
--- /dev/null
+++ b/web_src/js/utils/time.js
@@ -0,0 +1,46 @@
+import dayjs from 'dayjs';
+
+// Returns an array of millisecond-timestamps of start-of-week days (Sundays)
+export function startDaysBetween(startDate, endDate) {
+  // Ensure the start date is a Sunday
+  while (startDate.getDay() !== 0) {
+    startDate.setDate(startDate.getDate() + 1);
+  }
+
+  const start = dayjs(startDate);
+  const end = dayjs(endDate);
+  const startDays = [];
+
+  let current = start;
+  while (current.isBefore(end)) {
+    startDays.push(current.valueOf());
+    // we are adding 7 * 24 hours instead of 1 week because we don't want
+    // date library to use local time zone to calculate 1 week from now.
+    // local time zone is problematic because of daylight saving time (dst)
+    // used on some countries
+    current = current.add(7 * 24, 'hour');
+  }
+
+  return startDays;
+}
+
+export function firstStartDateAfterDate(inputDate) {
+  if (!(inputDate instanceof Date)) {
+    throw new Error('Invalid date');
+  }
+  const dayOfWeek = inputDate.getDay();
+  const daysUntilSunday = 7 - dayOfWeek;
+  const resultDate = new Date(inputDate.getTime());
+  resultDate.setDate(resultDate.getDate() + daysUntilSunday);
+  return resultDate.valueOf();
+}
+
+export function fillEmptyStartDaysWithZeroes(startDays, data) {
+  const result = {};
+
+  for (const startDay of startDays) {
+    result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0};
+  }
+
+  return Object.values(result);
+}
diff --git a/web_src/js/utils/time.test.js b/web_src/js/utils/time.test.js
new file mode 100644
index 0000000000..dd1114ce7f
--- /dev/null
+++ b/web_src/js/utils/time.test.js
@@ -0,0 +1,15 @@
+import {startDaysBetween} from './time.js';
+
+test('startDaysBetween', () => {
+  expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([
+    1708214400000,
+    1708819200000,
+    1709424000000,
+    1710028800000,
+    1710633600000,
+    1711238400000,
+    1711843200000,
+    1712448000000,
+    1713052800000,
+  ]);
+});