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" .}}
 
 	{{template "repo/header" .}}
-	
-		
-		
-
-		{{if (or (.Permission.CanRead $.UnitTypeIssues) (.Permission.CanRead $.UnitTypePullRequests))}}
-		
-		
-			{{if .Permission.CanRead $.UnitTypePullRequests}}
-				
-					{{if gt .Activity.ActivePRCount 0}}
-					
-					{{else}}
-					
-					{{end}}
-					{{ctx.Locale.TrN .Activity.ActivePRCount "repo.activity.active_prs_count_1" "repo.activity.active_prs_count_n" .Activity.ActivePRCount | Safe}}
-				
-			{{end}}
-			{{if .Permission.CanRead $.UnitTypeIssues}}
-				
-					{{if gt .Activity.ActiveIssueCount 0}}
-					
-					{{else}}
-					
-					{{end}}
-					{{ctx.Locale.TrN .Activity.ActiveIssueCount "repo.activity.active_issues_count_1" "repo.activity.active_issues_count_n" .Activity.ActiveIssueCount | Safe}}
-				
-			{{end}}
+	
+		
+			{{template "repo/navbar" .}}
 		
-		
-			{{if .Permission.CanRead $.UnitTypePullRequests}}
-				
-					{{svg "octicon-git-pull-request"}} {{.Activity.MergedPRCount}}
-					{{ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.merged_prs_count_1" "repo.activity.merged_prs_count_n"}}
-				
-				
-					{{svg "octicon-git-branch"}} {{.Activity.OpenedPRCount}}
-					{{ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.opened_prs_count_1" "repo.activity.opened_prs_count_n"}}
-				
-			{{end}}
-			{{if .Permission.CanRead $.UnitTypeIssues}}
-				
-					{{svg "octicon-issue-closed"}} {{.Activity.ClosedIssueCount}}
-					{{ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.closed_issues_count_1" "repo.activity.closed_issues_count_n"}}
-				
-				
-					{{svg "octicon-issue-opened"}} {{.Activity.OpenedIssueCount}}
-					{{ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.new_issues_count_1" "repo.activity.new_issues_count_n"}}
-				
-			{{end}}
+		
+			{{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}}
+			{{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}}
 		
-		{{end}}
-
-		{{if .Permission.CanRead $.UnitTypeCode}}
-			{{if eq .Activity.Code.CommitCountInAllBranches 0}}
-				
-				
-				
-			{{end}}
-			{{if gt .Activity.Code.CommitCountInAllBranches 0}}
-				
-					
-						{{ctx.Locale.Tr "repo.activity.git_stats_exclude_merges"}}
-						{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n" .Activity.Code.AuthorCount}}
-						{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_pushed_1" "repo.activity.git_stats_pushed_n"}}
-						{{ctx.Locale.TrN .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCount}}
-						{{ctx.Locale.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch}}
-						{{ctx.Locale.TrN .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCountInAllBranches}}
-						{{ctx.Locale.Tr "repo.activity.git_stats_push_to_all_branches"}}
-						{{ctx.Locale.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch}}
-						{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n" .Activity.Code.ChangedFiles}}
-						{{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"}}
-						{{ctx.Locale.TrN .Activity.Code.Additions "repo.activity.git_stats_addition_1" "repo.activity.git_stats_addition_n" .Activity.Code.Additions}}
-						{{ctx.Locale.Tr "repo.activity.git_stats_and_deletions"}}
-						{{ctx.Locale.TrN .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n" .Activity.Code.Deletions}}.
-					
-					
-				
-			{{end}}
-		{{end}}
-
-		{{if gt .Activity.PublishedReleaseCount 0}}
-			
-				{{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)
-				}}
-			
-			
-		{{end}}
-
-		{{if gt .Activity.MergedPRCount 0}}
-			
-				{{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)
-				}}
-			
-			
-		{{end}}
-
-		{{if gt .Activity.OpenedPRCount 0}}
-			
-				{{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)
-				}}
-			
-			
-		{{end}}
-
-		{{if gt .Activity.ClosedIssueCount 0}}
-			
-				{{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)
-				}}
-			
-			
-		{{end}}
-
-		{{if gt .Activity.OpenedIssueCount 0}}
-			
-				{{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)
-				}}
-			
-			
-		{{end}}
-
-		{{if gt .Activity.UnresolvedIssueCount 0}}
-			
-				{{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}}
-			
-			
-		{{end}}
 	
 {{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}}
+	
+	
+{{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 @@
+
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 @@
+
+
+{{if (or (.Permission.CanRead $.UnitTypeIssues) (.Permission.CanRead $.UnitTypePullRequests))}}
+
+
+	{{if .Permission.CanRead $.UnitTypePullRequests}}
+		
+			{{if gt .Activity.ActivePRCount 0}}
+			
+			{{else}}
+			
+			{{end}}
+			{{ctx.Locale.TrN .Activity.ActivePRCount "repo.activity.active_prs_count_1" "repo.activity.active_prs_count_n" .Activity.ActivePRCount | Safe}}
+		
+	{{end}}
+	{{if .Permission.CanRead $.UnitTypeIssues}}
+		
+			{{if gt .Activity.ActiveIssueCount 0}}
+			
+			{{else}}
+			
+			{{end}}
+			{{ctx.Locale.TrN .Activity.ActiveIssueCount "repo.activity.active_issues_count_1" "repo.activity.active_issues_count_n" .Activity.ActiveIssueCount | Safe}}
+		
+	{{end}}
+
+
+{{end}}
+
+{{if .Permission.CanRead $.UnitTypeCode}}
+	{{if eq .Activity.Code.CommitCountInAllBranches 0}}
+		
+		
+		
+	{{end}}
+	{{if gt .Activity.Code.CommitCountInAllBranches 0}}
+		
+			
+				{{ctx.Locale.Tr "repo.activity.git_stats_exclude_merges"}}
+				{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n" .Activity.Code.AuthorCount}}
+				{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_pushed_1" "repo.activity.git_stats_pushed_n"}}
+				{{ctx.Locale.TrN .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCount}}
+				{{ctx.Locale.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch}}
+				{{ctx.Locale.TrN .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCountInAllBranches}}
+				{{ctx.Locale.Tr "repo.activity.git_stats_push_to_all_branches"}}
+				{{ctx.Locale.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch}}
+				{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n" .Activity.Code.ChangedFiles}}
+				{{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"}}
+				{{ctx.Locale.TrN .Activity.Code.Additions "repo.activity.git_stats_addition_1" "repo.activity.git_stats_addition_n" .Activity.Code.Additions}}
+				{{ctx.Locale.Tr "repo.activity.git_stats_and_deletions"}}
+				{{ctx.Locale.TrN .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n" .Activity.Code.Deletions}}.
+			
+			
+		
+	{{end}}
+{{end}}
+
+{{if gt .Activity.PublishedReleaseCount 0}}
+	
+		{{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)
+		}}
+	
+	
+{{end}}
+
+{{if gt .Activity.MergedPRCount 0}}
+	
+		{{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)
+		}}
+	
+	
+{{end}}
+
+{{if gt .Activity.OpenedPRCount 0}}
+	
+		{{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)
+		}}
+	
+	
+{{end}}
+
+{{if gt .Activity.ClosedIssueCount 0}}
+	
+		{{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)
+		}}
+	
+	
+{{end}}
+
+{{if gt .Activity.OpenedIssueCount 0}}
+	
+		{{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)
+		}}
+	
+	
+{{end}}
+
+{{if gt .Activity.UnresolvedIssueCount 0}}
+	
+		{{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}}
+	
+	
+{{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 @@
+
+
+  
+    
+    
+      
+        
+          
+          {{ locale.loadingInfo }}
+        
+        
+          
+          {{ errorText }}
+        
+      
+      
+    
+    
+  
+
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,
+  ]);
+});