Load heatmap data asynchronously (#36622)
Fixes: https://github.com/go-gitea/gitea/issues/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>
This commit is contained in:
@@ -19,14 +19,14 @@ type UserHeatmapData struct {
|
|||||||
Contributions int64 `json:"contributions"`
|
Contributions int64 `json:"contributions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserHeatmapDataByUser returns an array of UserHeatmapData
|
// GetUserHeatmapDataByUser returns an array of UserHeatmapData, it checks whether doer can access user's activity
|
||||||
func GetUserHeatmapDataByUser(ctx context.Context, user, doer *user_model.User) ([]*UserHeatmapData, error) {
|
func GetUserHeatmapDataByUser(ctx context.Context, user, doer *user_model.User) ([]*UserHeatmapData, error) {
|
||||||
return getUserHeatmapData(ctx, user, nil, doer)
|
return getUserHeatmapData(ctx, user, nil, doer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserHeatmapDataByUserTeam returns an array of UserHeatmapData
|
// GetUserHeatmapDataByOrgTeam returns an array of UserHeatmapData, it checks whether doer can access org's activity
|
||||||
func GetUserHeatmapDataByUserTeam(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
|
func GetUserHeatmapDataByOrgTeam(ctx context.Context, org *organization.Organization, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
|
||||||
return getUserHeatmapData(ctx, user, team, doer)
|
return getUserHeatmapData(ctx, org.AsUser(), team, doer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
|
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
|
|||||||
OrderBy("timestamp").
|
OrderBy("timestamp").
|
||||||
Find(&hdata)
|
Find(&hdata)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTotalContributionsInHeatmap returns the total number of contributions in a heatmap
|
|
||||||
func GetTotalContributionsInHeatmap(hdata []*UserHeatmapData) int64 {
|
|
||||||
var total int64
|
|
||||||
for _, v := range hdata {
|
|
||||||
total += v.Contributions
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
activities_model "code.gitea.io/gitea/models/activities"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prepareHeatmapURL(ctx *context.Context) {
|
||||||
|
ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap
|
||||||
|
if !setting.Service.EnableUserHeatmap {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Org.Organization == nil {
|
||||||
|
// for individual user
|
||||||
|
ctx.Data["HeatmapURL"] = ctx.Doer.HomeLink() + "/-/heatmap"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// for org or team
|
||||||
|
heatmapURL := ctx.Org.Organization.OrganisationLink() + "/dashboard/-/heatmap"
|
||||||
|
if ctx.Org.Team != nil {
|
||||||
|
heatmapURL += "/" + url.PathEscape(ctx.Org.Team.LowerName)
|
||||||
|
}
|
||||||
|
ctx.Data["HeatmapURL"] = heatmapURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeatmapJSON(ctx *context.Context, hdata []*activities_model.UserHeatmapData) {
|
||||||
|
data := make([][2]int64, len(hdata))
|
||||||
|
var total int64
|
||||||
|
for i, v := range hdata {
|
||||||
|
data[i] = [2]int64{int64(v.Timestamp), v.Contributions}
|
||||||
|
total += v.Contributions
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, map[string]any{
|
||||||
|
"heatmapData": data,
|
||||||
|
"totalContributions": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardHeatmap returns heatmap data as JSON, for the individual user, organization or team dashboard.
|
||||||
|
func DashboardHeatmap(ctx *context.Context) {
|
||||||
|
if !setting.Service.EnableUserHeatmap {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var data []*activities_model.UserHeatmapData
|
||||||
|
var err error
|
||||||
|
if ctx.Org.Organization == nil {
|
||||||
|
data, err = activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
|
||||||
|
} else {
|
||||||
|
data, err = activities_model.GetUserHeatmapDataByOrgTeam(ctx, ctx.Org.Organization, ctx.Org.Team, ctx.Doer)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetUserHeatmapData", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeHeatmapJSON(ctx, data)
|
||||||
|
}
|
||||||
@@ -54,8 +54,8 @@ const (
|
|||||||
tplProfile templates.TplName = "user/profile"
|
tplProfile templates.TplName = "user/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getDashboardContextUser finds out which context user dashboard is being viewed as .
|
// prepareDashboardContextUserOrgTeams finds out which context user dashboard is being viewed as .
|
||||||
func getDashboardContextUser(ctx *context.Context) *user_model.User {
|
func prepareDashboardContextUserOrgTeams(ctx *context.Context) *user_model.User {
|
||||||
ctxUser := ctx.Doer
|
ctxUser := ctx.Doer
|
||||||
orgName := ctx.PathParam("org")
|
orgName := ctx.PathParam("org")
|
||||||
if len(orgName) > 0 {
|
if len(orgName) > 0 {
|
||||||
@@ -76,7 +76,7 @@ func getDashboardContextUser(ctx *context.Context) *user_model.User {
|
|||||||
|
|
||||||
// Dashboard render the dashboard page
|
// Dashboard render the dashboard page
|
||||||
func Dashboard(ctx *context.Context) {
|
func Dashboard(ctx *context.Context) {
|
||||||
ctxUser := getDashboardContextUser(ctx)
|
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -109,15 +109,7 @@ func Dashboard(ctx *context.Context) {
|
|||||||
"uid": uid,
|
"uid": uid,
|
||||||
}
|
}
|
||||||
|
|
||||||
if setting.Service.EnableUserHeatmap {
|
prepareHeatmapURL(ctx)
|
||||||
data, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.Doer)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("GetUserHeatmapDataByUserTeam", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Data["HeatmapData"] = data
|
|
||||||
ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
feeds, count, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{
|
feeds, count, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{
|
||||||
RequestedUser: ctxUser,
|
RequestedUser: ctxUser,
|
||||||
@@ -156,7 +148,7 @@ func Milestones(ctx *context.Context) {
|
|||||||
ctx.Data["Title"] = ctx.Tr("milestones")
|
ctx.Data["Title"] = ctx.Tr("milestones")
|
||||||
ctx.Data["PageIsMilestonesDashboard"] = true
|
ctx.Data["PageIsMilestonesDashboard"] = true
|
||||||
|
|
||||||
ctxUser := getDashboardContextUser(ctx)
|
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -371,7 +363,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
|
|||||||
// Return with NotFound or ServerError if unsuccessful.
|
// Return with NotFound or ServerError if unsuccessful.
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|
||||||
ctxUser := getDashboardContextUser(ctx)
|
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,15 +161,9 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
|||||||
ctx.Data["Cards"] = following
|
ctx.Data["Cards"] = following
|
||||||
total = int(numFollowing)
|
total = int(numFollowing)
|
||||||
case "activity":
|
case "activity":
|
||||||
// prepare heatmap data
|
if setting.Service.EnableUserHeatmap && activities_model.ActivityReadable(ctx.ContextUser, ctx.Doer) {
|
||||||
if setting.Service.EnableUserHeatmap {
|
ctx.Data["EnableHeatmap"] = true
|
||||||
data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
|
ctx.Data["HeatmapURL"] = ctx.ContextUser.HomeLink() + "/-/heatmap"
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("GetUserHeatmapDataByUser", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Data["HeatmapData"] = data
|
|
||||||
ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
date := ctx.FormString("date")
|
date := ctx.FormString("date")
|
||||||
|
|||||||
@@ -888,6 +888,8 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
m.Group("/{org}", func() {
|
m.Group("/{org}", func() {
|
||||||
m.Get("/dashboard", user.Dashboard)
|
m.Get("/dashboard", user.Dashboard)
|
||||||
m.Get("/dashboard/{team}", user.Dashboard)
|
m.Get("/dashboard/{team}", user.Dashboard)
|
||||||
|
m.Get("/dashboard/-/heatmap", user.DashboardHeatmap)
|
||||||
|
m.Get("/dashboard/-/heatmap/{team}", user.DashboardHeatmap)
|
||||||
m.Get("/issues", user.Issues)
|
m.Get("/issues", user.Issues)
|
||||||
m.Get("/issues/{team}", user.Issues)
|
m.Get("/issues/{team}", user.Issues)
|
||||||
m.Get("/pulls", user.Pulls)
|
m.Get("/pulls", user.Pulls)
|
||||||
@@ -1024,6 +1026,7 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.Get("/repositories", org.Repositories)
|
m.Get("/repositories", org.Repositories)
|
||||||
|
m.Get("/heatmap", user.DashboardHeatmap)
|
||||||
|
|
||||||
m.Group("/projects", func() {
|
m.Group("/projects", func() {
|
||||||
m.Group("", func() {
|
m.Group("", func() {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{{if .HeatmapData}}
|
{{if .EnableHeatmap}}
|
||||||
<div class="activity-heatmap-container">
|
<div class="activity-heatmap-container">
|
||||||
<div id="user-heatmap" class="is-loading"
|
<div id="user-heatmap" class="is-loading"
|
||||||
data-heatmap-data="{{JsonUtils.EncodeToString .HeatmapData}}"
|
data-heatmap-url="{{.HeatmapURL}}"
|
||||||
data-locale-total-contributions="{{ctx.Locale.Tr "heatmap.number_of_contributions_in_the_last_12_months" (ctx.Locale.PrettyNumber .HeatmapTotalContributions)}}"
|
data-locale-total-contributions="{{ctx.Locale.Tr "heatmap.number_of_contributions_in_the_last_12_months" "%s"}}"
|
||||||
data-locale-no-contributions="{{ctx.Locale.Tr "heatmap.no_contributions"}}"
|
data-locale-no-contributions="{{ctx.Locale.Tr "heatmap.no_contributions"}}"
|
||||||
data-locale-more="{{ctx.Locale.Tr "heatmap.more"}}"
|
data-locale-more="{{ctx.Locale.Tr "heatmap.more"}}"
|
||||||
data-locale-less="{{ctx.Locale.Tr "heatmap.less"}}"
|
data-locale-less="{{ctx.Locale.Tr "heatmap.less"}}"
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHeatmapEndpoints(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
// Mock time so fixture actions fall within the heatmap's time window
|
||||||
|
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
defer timeutil.MockUnset()
|
||||||
|
|
||||||
|
session := loginUser(t, "user2")
|
||||||
|
|
||||||
|
t.Run("UserProfile", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
req := NewRequest(t, "GET", "/user2/-/heatmap")
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
DecodeJSON(t, resp, &result)
|
||||||
|
assert.Contains(t, result, "heatmapData")
|
||||||
|
assert.Contains(t, result, "totalContributions")
|
||||||
|
assert.Positive(t, result["totalContributions"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OrgDashboard", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
req := NewRequest(t, "GET", "/org/org3/dashboard/-/heatmap")
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
DecodeJSON(t, resp, &result)
|
||||||
|
assert.Contains(t, result, "heatmapData")
|
||||||
|
assert.Contains(t, result, "totalContributions")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OrgTeamDashboard", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
req := NewRequest(t, "GET", "/org/org3/dashboard/-/heatmap/team1")
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
DecodeJSON(t, resp, &result)
|
||||||
|
assert.Contains(t, result, "heatmapData")
|
||||||
|
assert.Contains(t, result, "totalContributions")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
import {createApp} from 'vue';
|
import {createApp} from 'vue';
|
||||||
import ActivityHeatmap from '../components/ActivityHeatmap.vue';
|
import ActivityHeatmap from '../components/ActivityHeatmap.vue';
|
||||||
import {translateMonth, translateDay} from '../utils.ts';
|
import {translateMonth, translateDay} from '../utils.ts';
|
||||||
|
import {GET} from '../modules/fetch.ts';
|
||||||
|
|
||||||
export function initHeatmap() {
|
type HeatmapResponse = {
|
||||||
const el = document.querySelector('#user-heatmap');
|
heatmapData: Array<[number, number]>; // [[1617235200, 2]] = [unix timestamp, count]
|
||||||
|
totalContributions: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function initHeatmap() {
|
||||||
|
const el = document.querySelector<HTMLElement>('#user-heatmap');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const url = el.getAttribute('data-heatmap-url')!;
|
||||||
|
const resp = await GET(url);
|
||||||
|
if (!resp.ok) throw new Error(`Failed to load heatmap data: ${resp.status} ${resp.statusText}`);
|
||||||
|
const {heatmapData, totalContributions} = await resp.json() as HeatmapResponse;
|
||||||
|
|
||||||
const heatmap: Record<string, number> = {};
|
const heatmap: Record<string, number> = {};
|
||||||
for (const {contributions, timestamp} of JSON.parse(el.getAttribute('data-heatmap-data')!)) {
|
for (const [timestamp, contributions] of heatmapData) {
|
||||||
// Convert to user timezone and sum contributions by date
|
// Convert to user timezone and sum contributions by date
|
||||||
const dateStr = new Date(timestamp * 1000).toDateString();
|
const dateStr = new Date(timestamp * 1000).toDateString();
|
||||||
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
|
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
|
||||||
@@ -18,6 +29,9 @@ export function initHeatmap() {
|
|||||||
return {date: new Date(v), count: heatmap[v]};
|
return {date: new Date(v), count: heatmap[v]};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalFormatted = totalContributions.toLocaleString();
|
||||||
|
const textTotalContributions = el.getAttribute('data-locale-total-contributions')!.replace('%s', totalFormatted);
|
||||||
|
|
||||||
// last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/a83761cbbae3c2e3b4bced71e680f44432073ac8
|
// last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/a83761cbbae3c2e3b4bced71e680f44432073ac8
|
||||||
const locale = {
|
const locale = {
|
||||||
heatMapLocale: {
|
heatMapLocale: {
|
||||||
@@ -28,7 +42,7 @@ export function initHeatmap() {
|
|||||||
less: el.getAttribute('data-locale-less'),
|
less: el.getAttribute('data-locale-less'),
|
||||||
},
|
},
|
||||||
tooltipUnit: 'contributions',
|
tooltipUnit: 'contributions',
|
||||||
textTotalContributions: el.getAttribute('data-locale-total-contributions'),
|
textTotalContributions,
|
||||||
noDataText: el.getAttribute('data-locale-no-contributions'),
|
noDataText: el.getAttribute('data-locale-no-contributions'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user