Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions models/activities/user_heatmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ type UserHeatmapData struct {
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) {
return getUserHeatmapData(ctx, user, nil, doer)
}

// GetUserHeatmapDataByUserTeam returns an array of UserHeatmapData
func GetUserHeatmapDataByUserTeam(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
return getUserHeatmapData(ctx, user, team, doer)
// GetUserHeatmapDataByOrgTeam returns an array of UserHeatmapData, it checks whether doer can access org's activity
func GetUserHeatmapDataByOrgTeam(ctx context.Context, org *organization.Organization, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
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) {
Expand Down Expand Up @@ -71,12 +71,3 @@ func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organi
OrderBy("timestamp").
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
}
66 changes: 66 additions & 0 deletions routers/web/user/heatmap.go
Original file line number Diff line number Diff line change
@@ -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)
}
20 changes: 6 additions & 14 deletions routers/web/user/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ const (
tplProfile templates.TplName = "user/profile"
)

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

// Dashboard render the dashboard page
func Dashboard(ctx *context.Context) {
ctxUser := getDashboardContextUser(ctx)
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
if ctx.Written() {
return
}
Expand Down Expand Up @@ -109,15 +109,7 @@ func Dashboard(ctx *context.Context) {
"uid": uid,
}

if setting.Service.EnableUserHeatmap {
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)
}
prepareHeatmapURL(ctx)

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

ctxUser := getDashboardContextUser(ctx)
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
if ctx.Written() {
return
}
Expand Down Expand Up @@ -371,7 +363,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// Return with NotFound or ServerError if unsuccessful.
// ----------------------------------------------------

ctxUser := getDashboardContextUser(ctx)
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
if ctx.Written() {
return
}
Expand Down
12 changes: 3 additions & 9 deletions routers/web/user/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,9 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
ctx.Data["Cards"] = following
total = int(numFollowing)
case "activity":
// prepare heatmap data
if setting.Service.EnableUserHeatmap {
data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserHeatmapDataByUser", err)
return
}
ctx.Data["HeatmapData"] = data
ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
if setting.Service.EnableUserHeatmap && activities_model.ActivityReadable(ctx.ContextUser, ctx.Doer) {
ctx.Data["EnableHeatmap"] = true
ctx.Data["HeatmapURL"] = ctx.ContextUser.HomeLink() + "/-/heatmap"
}

date := ctx.FormString("date")
Expand Down
3 changes: 3 additions & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,8 @@ func registerWebRoutes(m *web.Router) {
m.Group("/{org}", func() {
m.Get("/dashboard", 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/{team}", user.Issues)
m.Get("/pulls", user.Pulls)
Expand Down Expand Up @@ -1024,6 +1026,7 @@ func registerWebRoutes(m *web.Router) {
}

m.Get("/repositories", org.Repositories)
m.Get("/heatmap", user.DashboardHeatmap)

m.Group("/projects", func() {
m.Group("", func() {
Expand Down
6 changes: 3 additions & 3 deletions templates/user/heatmap.tmpl
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{{if .HeatmapData}}
{{if .EnableHeatmap}}
<div class="activity-heatmap-container">
<div id="user-heatmap" class="is-loading"
data-heatmap-data="{{JsonUtils.EncodeToString .HeatmapData}}"
data-locale-total-contributions="{{ctx.Locale.Tr "heatmap.number_of_contributions_in_the_last_12_months" (ctx.Locale.PrettyNumber .HeatmapTotalContributions)}}"
data-heatmap-url="{{.HeatmapURL}}"
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-more="{{ctx.Locale.Tr "heatmap.more"}}"
data-locale-less="{{ctx.Locale.Tr "heatmap.less"}}"
Expand Down
59 changes: 59 additions & 0 deletions tests/integration/heatmap_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
22 changes: 18 additions & 4 deletions web_src/js/features/heatmap.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import {createApp} from 'vue';
import ActivityHeatmap from '../components/ActivityHeatmap.vue';
import {translateMonth, translateDay} from '../utils.ts';
import {GET} from '../modules/fetch.ts';

export function initHeatmap() {
const el = document.querySelector('#user-heatmap');
type HeatmapResponse = {
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;

try {
const url = el.getAttribute('data-heatmap-url')!;
const resp = await GET(url);
Comment thread
silverwind marked this conversation as resolved.
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> = {};
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
const dateStr = new Date(timestamp * 1000).toDateString();
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
Expand All @@ -18,6 +29,9 @@ export function initHeatmap() {
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
const locale = {
heatMapLocale: {
Expand All @@ -28,7 +42,7 @@ export function initHeatmap() {
less: el.getAttribute('data-locale-less'),
},
tooltipUnit: 'contributions',
textTotalContributions: el.getAttribute('data-locale-total-contributions'),
textTotalContributions,
noDataText: el.getAttribute('data-locale-no-contributions'),
};

Expand Down