diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 96345f51f8..5f34bc4c1d 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1912,6 +1912,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
@@ -1977,6 +1979,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 7aa9bb0795..a6288caaf6 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1392,6 +1392,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,
+  ]);
+});