Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
47 changes: 41 additions & 6 deletions routers/web/user/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"bytes"
"fmt"
"net/http"
"net/url"
"regexp"
"slices"
"sort"
Expand Down Expand Up @@ -110,13 +111,16 @@ func Dashboard(ctx *context.Context) {
}

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["EnableHeatmap"] = true
if ctx.Org.Organization != nil {
heatmapURL := ctx.Org.Organization.OrganisationLink() + "/dashboard"
if ctx.Org.Team != nil {
heatmapURL += "/" + url.PathEscape(ctx.Org.Team.LowerName)
}
ctx.Data["HeatmapURL"] = heatmapURL + "/-/heatmap"
} else {
ctx.Data["HeatmapURL"] = ctx.Doer.HomeLink() + "/-/heatmap"
}
ctx.Data["HeatmapData"] = data
ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
}

feeds, count, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{
Expand Down Expand Up @@ -145,6 +149,37 @@ func Dashboard(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplDashboard)
}

func writeHeatmapJSON(ctx *context.Context, hdata []*activities_model.UserHeatmapData, err error) {
if err != nil {
ctx.ServerError("GetUserHeatmapData", err)
return
}
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 for the dashboard as JSON
func DashboardHeatmap(ctx *context.Context) {
if !setting.Service.EnableUserHeatmap {
ctx.NotFound(nil)
return
}
ctxUser := getDashboardContextUser(ctx)
if ctx.Written() {
return
}
hdata, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.Doer)
writeHeatmapJSON(ctx, hdata, err)
}

// Milestones render the user milestones page
func Milestones(ctx *context.Context) {
if unit.TypeIssues.UnitGlobalDisabled() && unit.TypePullRequests.UnitGlobalDisabled() {
Expand Down
22 changes: 13 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 Expand Up @@ -320,6 +314,16 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
ctx.Data["Page"] = pager
}

// Heatmap returns heatmap data for a user profile as JSON
func Heatmap(ctx *context.Context) {
if !setting.Service.EnableUserHeatmap || !activities_model.ActivityReadable(ctx.ContextUser, ctx.Doer) {
ctx.NotFound(nil)
return
}
hdata, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
writeHeatmapJSON(ctx, hdata, err)
}

// ActionUserFollow is for follow/unfollow user request
func ActionUserFollow(ctx *context.Context) {
var err error
Expand Down
3 changes: 3 additions & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -887,7 +887,9 @@ func registerWebRoutes(m *web.Router) {

m.Group("/{org}", func() {
m.Get("/dashboard", user.Dashboard)
m.Get("/dashboard/-/heatmap", user.DashboardHeatmap)
m.Get("/dashboard/{team}", user.Dashboard)
m.Get("/dashboard/{team}/-/heatmap", 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.Heatmap)

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) {
Comment thread
silverwind marked this conversation as resolved.
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/team1/-/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")
})
}
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
Loading