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, + ]); +});