Skip to content

Commit d6be18e

Browse files
silverwindclaudewxiaoguang
authored
Load heatmap data asynchronously (#36622)
Fixes: #21045 - Move heatmap data loading from synchronous server-side rendering to async client-side fetch via dedicated JSON endpoints - Dashboard and user profile pages no longer block on the expensive heatmap DB query during HTML generation - Use compact `[[timestamp,count]]` JSON format instead of `[{"timestamp":N,"contributions":N}]` to reduce payload size - Public API (`/api/v1/users/{username}/heatmap`) remains unchanged - Heatmap rendering is unchanged, still shows a spinner as before, which will now spin a litte bit longer. Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
1 parent 883af8d commit d6be18e

File tree

8 files changed

+162
-43
lines changed

8 files changed

+162
-43
lines changed

models/activities/user_heatmap.go

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ type UserHeatmapData struct {
1919
Contributions int64 `json:"contributions"`
2020
}
2121

22-
// GetUserHeatmapDataByUser returns an array of UserHeatmapData
22+
// GetUserHeatmapDataByUser returns an array of UserHeatmapData, it checks whether doer can access user's activity
2323
func GetUserHeatmapDataByUser(ctx context.Context, user, doer *user_model.User) ([]*UserHeatmapData, error) {
2424
return getUserHeatmapData(ctx, user, nil, doer)
2525
}
2626

27-
// GetUserHeatmapDataByUserTeam returns an array of UserHeatmapData
28-
func GetUserHeatmapDataByUserTeam(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
29-
return getUserHeatmapData(ctx, user, team, doer)
27+
// GetUserHeatmapDataByOrgTeam returns an array of UserHeatmapData, it checks whether doer can access org's activity
28+
func GetUserHeatmapDataByOrgTeam(ctx context.Context, org *organization.Organization, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
29+
return getUserHeatmapData(ctx, org.AsUser(), team, doer)
3030
}
3131

3232
func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
@@ -71,12 +71,3 @@ func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organi
7171
OrderBy("timestamp").
7272
Find(&hdata)
7373
}
74-
75-
// GetTotalContributionsInHeatmap returns the total number of contributions in a heatmap
76-
func GetTotalContributionsInHeatmap(hdata []*UserHeatmapData) int64 {
77-
var total int64
78-
for _, v := range hdata {
79-
total += v.Contributions
80-
}
81-
return total
82-
}

routers/web/user/heatmap.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package user
5+
6+
import (
7+
"net/http"
8+
"net/url"
9+
10+
activities_model "code.gitea.io/gitea/models/activities"
11+
"code.gitea.io/gitea/modules/setting"
12+
"code.gitea.io/gitea/services/context"
13+
)
14+
15+
func prepareHeatmapURL(ctx *context.Context) {
16+
ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap
17+
if !setting.Service.EnableUserHeatmap {
18+
return
19+
}
20+
21+
if ctx.Org.Organization == nil {
22+
// for individual user
23+
ctx.Data["HeatmapURL"] = ctx.Doer.HomeLink() + "/-/heatmap"
24+
return
25+
}
26+
27+
// for org or team
28+
heatmapURL := ctx.Org.Organization.OrganisationLink() + "/dashboard/-/heatmap"
29+
if ctx.Org.Team != nil {
30+
heatmapURL += "/" + url.PathEscape(ctx.Org.Team.LowerName)
31+
}
32+
ctx.Data["HeatmapURL"] = heatmapURL
33+
}
34+
35+
func writeHeatmapJSON(ctx *context.Context, hdata []*activities_model.UserHeatmapData) {
36+
data := make([][2]int64, len(hdata))
37+
var total int64
38+
for i, v := range hdata {
39+
data[i] = [2]int64{int64(v.Timestamp), v.Contributions}
40+
total += v.Contributions
41+
}
42+
ctx.JSON(http.StatusOK, map[string]any{
43+
"heatmapData": data,
44+
"totalContributions": total,
45+
})
46+
}
47+
48+
// DashboardHeatmap returns heatmap data as JSON, for the individual user, organization or team dashboard.
49+
func DashboardHeatmap(ctx *context.Context) {
50+
if !setting.Service.EnableUserHeatmap {
51+
ctx.NotFound(nil)
52+
return
53+
}
54+
var data []*activities_model.UserHeatmapData
55+
var err error
56+
if ctx.Org.Organization == nil {
57+
data, err = activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
58+
} else {
59+
data, err = activities_model.GetUserHeatmapDataByOrgTeam(ctx, ctx.Org.Organization, ctx.Org.Team, ctx.Doer)
60+
}
61+
if err != nil {
62+
ctx.ServerError("GetUserHeatmapData", err)
63+
return
64+
}
65+
writeHeatmapJSON(ctx, data)
66+
}

routers/web/user/home.go

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ const (
5454
tplProfile templates.TplName = "user/profile"
5555
)
5656

57-
// getDashboardContextUser finds out which context user dashboard is being viewed as .
58-
func getDashboardContextUser(ctx *context.Context) *user_model.User {
57+
// prepareDashboardContextUserOrgTeams finds out which context user dashboard is being viewed as .
58+
func prepareDashboardContextUserOrgTeams(ctx *context.Context) *user_model.User {
5959
ctxUser := ctx.Doer
6060
orgName := ctx.PathParam("org")
6161
if len(orgName) > 0 {
@@ -76,7 +76,7 @@ func getDashboardContextUser(ctx *context.Context) *user_model.User {
7676

7777
// Dashboard render the dashboard page
7878
func Dashboard(ctx *context.Context) {
79-
ctxUser := getDashboardContextUser(ctx)
79+
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
8080
if ctx.Written() {
8181
return
8282
}
@@ -109,15 +109,7 @@ func Dashboard(ctx *context.Context) {
109109
"uid": uid,
110110
}
111111

112-
if setting.Service.EnableUserHeatmap {
113-
data, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.Doer)
114-
if err != nil {
115-
ctx.ServerError("GetUserHeatmapDataByUserTeam", err)
116-
return
117-
}
118-
ctx.Data["HeatmapData"] = data
119-
ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
120-
}
112+
prepareHeatmapURL(ctx)
121113

122114
feeds, count, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{
123115
RequestedUser: ctxUser,
@@ -156,7 +148,7 @@ func Milestones(ctx *context.Context) {
156148
ctx.Data["Title"] = ctx.Tr("milestones")
157149
ctx.Data["PageIsMilestonesDashboard"] = true
158150

159-
ctxUser := getDashboardContextUser(ctx)
151+
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
160152
if ctx.Written() {
161153
return
162154
}
@@ -371,7 +363,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
371363
// Return with NotFound or ServerError if unsuccessful.
372364
// ----------------------------------------------------
373365

374-
ctxUser := getDashboardContextUser(ctx)
366+
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
375367
if ctx.Written() {
376368
return
377369
}

routers/web/user/profile.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -161,15 +161,9 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
161161
ctx.Data["Cards"] = following
162162
total = int(numFollowing)
163163
case "activity":
164-
// prepare heatmap data
165-
if setting.Service.EnableUserHeatmap {
166-
data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
167-
if err != nil {
168-
ctx.ServerError("GetUserHeatmapDataByUser", err)
169-
return
170-
}
171-
ctx.Data["HeatmapData"] = data
172-
ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
164+
if setting.Service.EnableUserHeatmap && activities_model.ActivityReadable(ctx.ContextUser, ctx.Doer) {
165+
ctx.Data["EnableHeatmap"] = true
166+
ctx.Data["HeatmapURL"] = ctx.ContextUser.HomeLink() + "/-/heatmap"
173167
}
174168

175169
date := ctx.FormString("date")

routers/web/web.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,8 @@ func registerWebRoutes(m *web.Router) {
888888
m.Group("/{org}", func() {
889889
m.Get("/dashboard", user.Dashboard)
890890
m.Get("/dashboard/{team}", user.Dashboard)
891+
m.Get("/dashboard/-/heatmap", user.DashboardHeatmap)
892+
m.Get("/dashboard/-/heatmap/{team}", user.DashboardHeatmap)
891893
m.Get("/issues", user.Issues)
892894
m.Get("/issues/{team}", user.Issues)
893895
m.Get("/pulls", user.Pulls)
@@ -1024,6 +1026,7 @@ func registerWebRoutes(m *web.Router) {
10241026
}
10251027

10261028
m.Get("/repositories", org.Repositories)
1029+
m.Get("/heatmap", user.DashboardHeatmap)
10271030

10281031
m.Group("/projects", func() {
10291032
m.Group("", func() {

templates/user/heatmap.tmpl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
{{if .HeatmapData}}
1+
{{if .EnableHeatmap}}
22
<div class="activity-heatmap-container">
33
<div id="user-heatmap" class="is-loading"
4-
data-heatmap-data="{{JsonUtils.EncodeToString .HeatmapData}}"
5-
data-locale-total-contributions="{{ctx.Locale.Tr "heatmap.number_of_contributions_in_the_last_12_months" (ctx.Locale.PrettyNumber .HeatmapTotalContributions)}}"
4+
data-heatmap-url="{{.HeatmapURL}}"
5+
data-locale-total-contributions="{{ctx.Locale.Tr "heatmap.number_of_contributions_in_the_last_12_months" "%s"}}"
66
data-locale-no-contributions="{{ctx.Locale.Tr "heatmap.no_contributions"}}"
77
data-locale-more="{{ctx.Locale.Tr "heatmap.more"}}"
88
data-locale-less="{{ctx.Locale.Tr "heatmap.less"}}"

tests/integration/heatmap_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package integration
5+
6+
import (
7+
"net/http"
8+
"testing"
9+
"time"
10+
11+
"code.gitea.io/gitea/modules/timeutil"
12+
"code.gitea.io/gitea/tests"
13+
14+
"github.com/stretchr/testify/assert"
15+
)
16+
17+
func TestHeatmapEndpoints(t *testing.T) {
18+
defer tests.PrepareTestEnv(t)()
19+
20+
// Mock time so fixture actions fall within the heatmap's time window
21+
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
22+
defer timeutil.MockUnset()
23+
24+
session := loginUser(t, "user2")
25+
26+
t.Run("UserProfile", func(t *testing.T) {
27+
defer tests.PrintCurrentTest(t)()
28+
req := NewRequest(t, "GET", "/user2/-/heatmap")
29+
resp := session.MakeRequest(t, req, http.StatusOK)
30+
31+
var result map[string]any
32+
DecodeJSON(t, resp, &result)
33+
assert.Contains(t, result, "heatmapData")
34+
assert.Contains(t, result, "totalContributions")
35+
assert.Positive(t, result["totalContributions"])
36+
})
37+
38+
t.Run("OrgDashboard", func(t *testing.T) {
39+
defer tests.PrintCurrentTest(t)()
40+
req := NewRequest(t, "GET", "/org/org3/dashboard/-/heatmap")
41+
resp := session.MakeRequest(t, req, http.StatusOK)
42+
43+
var result map[string]any
44+
DecodeJSON(t, resp, &result)
45+
assert.Contains(t, result, "heatmapData")
46+
assert.Contains(t, result, "totalContributions")
47+
})
48+
49+
t.Run("OrgTeamDashboard", func(t *testing.T) {
50+
defer tests.PrintCurrentTest(t)()
51+
req := NewRequest(t, "GET", "/org/org3/dashboard/-/heatmap/team1")
52+
resp := session.MakeRequest(t, req, http.StatusOK)
53+
54+
var result map[string]any
55+
DecodeJSON(t, resp, &result)
56+
assert.Contains(t, result, "heatmapData")
57+
assert.Contains(t, result, "totalContributions")
58+
})
59+
}

web_src/js/features/heatmap.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
import {createApp} from 'vue';
22
import ActivityHeatmap from '../components/ActivityHeatmap.vue';
33
import {translateMonth, translateDay} from '../utils.ts';
4+
import {GET} from '../modules/fetch.ts';
45

5-
export function initHeatmap() {
6-
const el = document.querySelector('#user-heatmap');
6+
type HeatmapResponse = {
7+
heatmapData: Array<[number, number]>; // [[1617235200, 2]] = [unix timestamp, count]
8+
totalContributions: number;
9+
};
10+
11+
export async function initHeatmap() {
12+
const el = document.querySelector<HTMLElement>('#user-heatmap');
713
if (!el) return;
814

915
try {
16+
const url = el.getAttribute('data-heatmap-url')!;
17+
const resp = await GET(url);
18+
if (!resp.ok) throw new Error(`Failed to load heatmap data: ${resp.status} ${resp.statusText}`);
19+
const {heatmapData, totalContributions} = await resp.json() as HeatmapResponse;
20+
1021
const heatmap: Record<string, number> = {};
11-
for (const {contributions, timestamp} of JSON.parse(el.getAttribute('data-heatmap-data')!)) {
22+
for (const [timestamp, contributions] of heatmapData) {
1223
// Convert to user timezone and sum contributions by date
1324
const dateStr = new Date(timestamp * 1000).toDateString();
1425
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
@@ -18,6 +29,9 @@ export function initHeatmap() {
1829
return {date: new Date(v), count: heatmap[v]};
1930
});
2031

32+
const totalFormatted = totalContributions.toLocaleString();
33+
const textTotalContributions = el.getAttribute('data-locale-total-contributions')!.replace('%s', totalFormatted);
34+
2135
// last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/a83761cbbae3c2e3b4bced71e680f44432073ac8
2236
const locale = {
2337
heatMapLocale: {
@@ -28,7 +42,7 @@ export function initHeatmap() {
2842
less: el.getAttribute('data-locale-less'),
2943
},
3044
tooltipUnit: 'contributions',
31-
textTotalContributions: el.getAttribute('data-locale-total-contributions'),
45+
textTotalContributions,
3246
noDataText: el.getAttribute('data-locale-no-contributions'),
3347
};
3448

0 commit comments

Comments
 (0)