2
0

User action heatmap (#5131)

* Added basic heatmap data

* Added extra case for sqlite

* Built basic heatmap into user profile

* Get contribution data from api & styling

* Fixed lint & added extra group by statements for all database types

* generated swagger spec

* generated swagger spec

* generated swagger spec

* fixed swagger spec

* fmt

* Added tests

* Added setting to enable/disable user heatmap

* Added locale for loading text

* Removed UseTiDB

* Updated librejs & moment.js

* Fixed import order

* Fixed heatmap in postgresql

* Update docs/content/doc/advanced/config-cheat-sheet.en-us.md

Co-Authored-By: kolaente <konrad@kola-entertainments.de>

* Added copyright header

* Fixed a bug to show the heatmap for the actual user instead of the currently logged in

* Added integration test for heatmaps

* Added a heatmap on the dashboard

* Fixed timestamp parsing

* Hide heatmap on mobile

* optimized postgresql group by query

* Improved sqlite group by statement
This commit is contained in:
kolaente
2018-10-23 04:57:42 +02:00
committed by Lunny Xiao
parent f38fce916e
commit 6759237eda
27 changed files with 649 additions and 1 deletions

View File

@@ -193,6 +193,7 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
- `RECAPTCHA_SECRET`: **""**: Go to https://www.google.com/recaptcha/admin to get a secret for recaptcha.
- `RECAPTCHA_SITEKEY`: **""**: Go to https://www.google.com/recaptcha/admin to get a sitekey for recaptcha.
- `DEFAULT_ENABLE_DEPENDENCIES`: **true** Enable this to have dependencies enabled by default.
- `ENABLE_USER_HEATMAP`: **true** Enable this to display the heatmap on users profiles.
## Webhook (`webhook`)

View File

@@ -0,0 +1,30 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.package models
package integrations
import (
"code.gitea.io/gitea/models"
"fmt"
"github.com/stretchr/testify/assert"
"net/http"
"testing"
)
func TestUserHeatmap(t *testing.T) {
prepareTestEnv(t)
adminUsername := "user1"
normalUsername := "user2"
session := loginUser(t, adminUsername)
urlStr := fmt.Sprintf("/api/v1/users/%s/heatmap", normalUsername)
req := NewRequest(t, "GET", urlStr)
resp := session.MakeRequest(t, req, http.StatusOK)
var heatmap []*models.UserHeatmapData
DecodeJSON(t, resp, &heatmap)
var dummyheatmap []*models.UserHeatmapData
dummyheatmap = append(dummyheatmap, &models.UserHeatmapData{Timestamp: 1540080000, Contributions: 1})
assert.Equal(t, dummyheatmap, heatmap)
}

View File

@@ -5,6 +5,7 @@
act_user_id: 2
repo_id: 2
is_private: true
created_unix: 1540139562
-
id: 2

View File

@@ -48,6 +48,7 @@ func MainTest(m *testing.M, pathToGiteaRoot string) {
setting.RunUser = "runuser"
setting.SSH.Port = 3000
setting.SSH.Domain = "try.gitea.io"
setting.UseSQLite3 = true
setting.RepoRootPath, err = ioutil.TempDir(os.TempDir(), "repos")
if err != nil {
fatalTestError("TempDir: %v\n", err)

40
models/user_heatmap.go Normal file
View File

@@ -0,0 +1,40 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.package models
package models
import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
// UserHeatmapData represents the data needed to create a heatmap
type UserHeatmapData struct {
Timestamp util.TimeStamp `json:"timestamp"`
Contributions int64 `json:"contributions"`
}
// GetUserHeatmapDataByUser returns an array of UserHeatmapData
func GetUserHeatmapDataByUser(user *User) (hdata []*UserHeatmapData, err error) {
var groupBy string
switch {
case setting.UseSQLite3:
groupBy = "strftime('%s', strftime('%Y-%m-%d', created_unix, 'unixepoch'))"
case setting.UseMySQL:
groupBy = "UNIX_TIMESTAMP(DATE_FORMAT(FROM_UNIXTIME(created_unix), '%Y%m%d'))"
case setting.UsePostgreSQL:
groupBy = "extract(epoch from date_trunc('day', to_timestamp(created_unix)))"
case setting.UseMSSQL:
groupBy = "dateadd(DAY,0, datediff(day,0, dateadd(s, created_unix, '19700101')))"
}
err = x.Select(groupBy+" as timestamp, count(user_id) as contributions").
Table("action").
Where("user_id = ?", user.ID).
And("created_unix > ?", (util.TimeStampNow() - 31536000)).
GroupBy("timestamp").
OrderBy("timestamp").
Find(&hdata)
return
}

View File

@@ -0,0 +1,33 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.package models
package models
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestGetUserHeatmapDataByUser(t *testing.T) {
// Prepare
assert.NoError(t, PrepareTestDatabase())
// Insert some action
user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
// get the action for comparison
actions, err := GetFeeds(GetFeedsOptions{
RequestedUser: user,
RequestingUserID: user.ID,
IncludePrivate: true,
OnlyPerformedBy: false,
IncludeDeleted: true,
})
assert.NoError(t, err)
// Get the heatmap and compare
heatmap, err := GetUserHeatmapDataByUser(user)
assert.NoError(t, err)
assert.Equal(t, len(actions), len(heatmap))
}

View File

@@ -1218,6 +1218,7 @@ var Service struct {
DefaultEnableDependencies bool
DefaultAllowOnlyContributorsToTrackTime bool
NoReplyAddress string
EnableUserHeatmap bool
// OpenID settings
EnableOpenIDSignIn bool
@@ -1249,6 +1250,7 @@ func newService() {
Service.DefaultEnableDependencies = sec.Key("DEFAULT_ENABLE_DEPENDENCIES").MustBool(true)
Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").MustBool(true)
Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply.example.org")
Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true)
sec = Cfg.Section("openid")
Service.EnableOpenIDSignIn = sec.Key("ENABLE_OPENID_SIGNIN").MustBool(!InstallLock)

View File

@@ -320,6 +320,7 @@ starred = Starred Repositories
following = Following
follow = Follow
unfollow = Unfollow
heatmap.loading = Loading Heatmap…
form.name_reserved = The username '%s' is reserved.
form.name_pattern_not_allowed = The pattern '%s' is not allowed in a username.

View File

File diff suppressed because one or more lines are too long

View File

@@ -588,3 +588,20 @@ footer {
border-bottom-width: 0 !important;
margin-bottom: 2px !important;
}
#user-heatmap{
width: 107%; // Fixes newest contributions not showing
text-align: center;
margin: 40px 0 30px;
svg:not(:root) {
overflow: inherit;
padding: 0 !important;
}
@media only screen and (max-width: 1200px) {
& {
display: none;
}
}
}

View File

@@ -58,6 +58,10 @@
.ui.repository.list {
margin-top: 25px;
}
#loading-heatmap{
margin-bottom: 1em;
}
}
&.followers {

View File

@@ -58,3 +58,12 @@ Version: 4.3.0
File(s): /vendor/assets/swagger-ui/
Version: 3.0.4
File(s): /vendor/plugins/d3/
Version: 4.13.0
File(s): /vendor/plugins/calendar-heatmap/
Version: 337b431
File(s): /vendor/plugins/moment/
Version: 2.22.2

View File

@@ -135,6 +135,21 @@
<td><a href="https://github.com/swagger-api/swagger-ui/blob/master/LICENSE">Apache-2.0</a></td>
<td><a href="https://github.com/swagger-api/swagger-ui/archive/v3.0.4.tar.gz">swagger-ui-v3.0.4.tar.gz</a></td>
</tr>
<tr>
<td><a href="./plugins/d3/">d3</a></td>
<td><a href="https://github.com/d3/d3/blob/master/LICENSE">BSD 3-Clause</a></td>
<td><a href="https://github.com/d3/d3/releases/download/v4.13.0/d3.zip">d3.zip</a></td>
</tr>
<tr>
<td><a href="./plugins/calendar-heatmap/">calendar-heatmap</a></td>
<td><a href="https://github.com/DKirwan/calendar-heatmap/blob/master/LICENSE">MIT</a></td>
<td><a href="https://github.com/DKirwan/calendar-heatmap/archive/master.zip">337b431.zip</a></td>
</tr>
<tr>
<td><a href="./plugins/moment/">moment.js</a></td>
<td><a href="https://github.com/moment/moment/blob/develop/LICENSE">MIT</a></td>
<td><a href="https://github.com/moment/moment/archive/2.22.2.tar.gz">0.4.1.tar.gz</a></td>
</tr>
</tbody>
</table>
</body>

View File

@@ -0,0 +1,27 @@
text.month-name,
text.calendar-heatmap-legend-text,
text.day-initial {
font-size: 10px;
fill: inherit;
font-family: Helvetica, arial, 'Open Sans', sans-serif;
}
rect.day-cell:hover {
stroke: #555555;
stroke-width: 1px;
}
.day-cell-tooltip {
position: absolute;
z-index: 9999;
padding: 5px 9px;
color: #bbbbbb;
font-size: 12px;
background: rgba(0, 0, 0, 0.85);
border-radius: 3px;
text-align: center;
}
.day-cell-tooltip > span {
font-family: Helvetica, arial, 'Open Sans', sans-serif
}
.calendar-heatmap {
box-sizing: initial;
}

View File

@@ -0,0 +1,311 @@
// https://github.com/DKirwan/calendar-heatmap
function calendarHeatmap() {
// defaults
var width = 750;
var height = 110;
var legendWidth = 150;
var selector = 'body';
var SQUARE_LENGTH = 11;
var SQUARE_PADDING = 2;
var MONTH_LABEL_PADDING = 6;
var now = moment().endOf('day').toDate();
var yearAgo = moment().startOf('day').subtract(1, 'year').toDate();
var startDate = null;
var counterMap= {};
var data = [];
var max = null;
var colorRange = ['#D8E6E7', '#218380'];
var tooltipEnabled = true;
var tooltipUnit = 'contribution';
var legendEnabled = true;
var onClick = null;
var weekStart = 1; //0 for Sunday, 1 for Monday
var locale = {
months: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
days: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],
No: 'No',
on: 'on',
Less: 'Less',
More: 'More'
};
var v = Number(d3.version.split('.')[0]);
// setters and getters
chart.data = function (value) {
if (!arguments.length) { return data; }
data = value;
counterMap= {};
data.forEach(function (element, index) {
var key= moment(element.date).format( 'YYYY-MM-DD' );
var counter= counterMap[key] || 0;
counterMap[key]= counter + element.count;
});
return chart;
};
chart.max = function (value) {
if (!arguments.length) { return max; }
max = value;
return chart;
};
chart.selector = function (value) {
if (!arguments.length) { return selector; }
selector = value;
return chart;
};
chart.startDate = function (value) {
if (!arguments.length) { return startDate; }
yearAgo = value;
now = moment(value).endOf('day').add(1, 'year').toDate();
return chart;
};
chart.colorRange = function (value) {
if (!arguments.length) { return colorRange; }
colorRange = value;
return chart;
};
chart.tooltipEnabled = function (value) {
if (!arguments.length) { return tooltipEnabled; }
tooltipEnabled = value;
return chart;
};
chart.tooltipUnit = function (value) {
if (!arguments.length) { return tooltipUnit; }
tooltipUnit = value;
return chart;
};
chart.legendEnabled = function (value) {
if (!arguments.length) { return legendEnabled; }
legendEnabled = value;
return chart;
};
chart.onClick = function (value) {
if (!arguments.length) { return onClick(); }
onClick = value;
return chart;
};
chart.locale = function (value) {
if (!arguments.length) { return locale; }
locale = value;
return chart;
};
function chart() {
d3.select(chart.selector()).selectAll('svg.calendar-heatmap').remove(); // remove the existing chart, if it exists
var dateRange = ((d3.time && d3.time.days) || d3.timeDays)(yearAgo, now); // generates an array of date objects within the specified range
var monthRange = ((d3.time && d3.time.months) || d3.timeMonths)(moment(yearAgo).startOf('month').toDate(), now); // it ignores the first month if the 1st date is after the start of the month
var firstDate = moment(dateRange[0]);
if (chart.data().length == 0) {
max = 0;
} else if (max === null) {
max = d3.max(chart.data(), function (d) { return d.count; }); // max data value
}
// color range
var color = ((d3.scale && d3.scale.linear) || d3.scaleLinear)()
.range(chart.colorRange())
.domain([0, max]);
var tooltip;
var dayRects;
drawChart();
function drawChart() {
var svg = d3.select(chart.selector())
.style('position', 'relative')
.append('svg')
.attr('width', width)
.attr('class', 'calendar-heatmap')
.attr('height', height)
.style('padding', '36px');
dayRects = svg.selectAll('.day-cell')
.data(dateRange); // array of days for the last yr
var enterSelection = dayRects.enter().append('rect')
.attr('class', 'day-cell')
.attr('width', SQUARE_LENGTH)
.attr('height', SQUARE_LENGTH)
.attr('fill', function(d) { return color(countForDate(d)); })
.attr('x', function (d, i) {
var cellDate = moment(d);
var result = cellDate.week() - firstDate.week() + (firstDate.weeksInYear() * (cellDate.weekYear() - firstDate.weekYear()));
return result * (SQUARE_LENGTH + SQUARE_PADDING);
})
.attr('y', function (d, i) {
return MONTH_LABEL_PADDING + formatWeekday(d.getDay()) * (SQUARE_LENGTH + SQUARE_PADDING);
});
if (typeof onClick === 'function') {
(v === 3 ? enterSelection : enterSelection.merge(dayRects)).on('click', function(d) {
var count = countForDate(d);
onClick({ date: d, count: count});
});
}
if (chart.tooltipEnabled()) {
(v === 3 ? enterSelection : enterSelection.merge(dayRects)).on('mouseover', function(d, i) {
tooltip = d3.select(chart.selector())
.append('div')
.attr('class', 'day-cell-tooltip')
.html(tooltipHTMLForDate(d))
.style('left', function () { return Math.floor(i / 7) * SQUARE_LENGTH + 'px'; })
.style('top', function () {
return formatWeekday(d.getDay()) * (SQUARE_LENGTH + SQUARE_PADDING) + MONTH_LABEL_PADDING * 2 + 'px';
});
})
.on('mouseout', function (d, i) {
tooltip.remove();
});
}
if (chart.legendEnabled()) {
var colorRange = [color(0)];
for (var i = 3; i > 0; i--) {
colorRange.push(color(max / i));
}
var legendGroup = svg.append('g');
legendGroup.selectAll('.calendar-heatmap-legend')
.data(colorRange)
.enter()
.append('rect')
.attr('class', 'calendar-heatmap-legend')
.attr('width', SQUARE_LENGTH)
.attr('height', SQUARE_LENGTH)
.attr('x', function (d, i) { return (width - legendWidth) + (i + 1) * 13; })
.attr('y', height + SQUARE_PADDING)
.attr('fill', function (d) { return d; });
legendGroup.append('text')
.attr('class', 'calendar-heatmap-legend-text calendar-heatmap-legend-text-less')
.attr('x', width - legendWidth - 13)
.attr('y', height + SQUARE_LENGTH)
.text(locale.Less);
legendGroup.append('text')
.attr('class', 'calendar-heatmap-legend-text calendar-heatmap-legend-text-more')
.attr('x', (width - legendWidth + SQUARE_PADDING) + (colorRange.length + 1) * 13)
.attr('y', height + SQUARE_LENGTH)
.text(locale.More);
}
dayRects.exit().remove();
var monthLabels = svg.selectAll('.month')
.data(monthRange)
.enter().append('text')
.attr('class', 'month-name')
.text(function (d) {
return locale.months[d.getMonth()];
})
.attr('x', function (d, i) {
var matchIndex = 0;
dateRange.find(function (element, index) {
matchIndex = index;
return moment(d).isSame(element, 'month') && moment(d).isSame(element, 'year');
});
return Math.floor(matchIndex / 7) * (SQUARE_LENGTH + SQUARE_PADDING);
})
.attr('y', 0); // fix these to the top
locale.days.forEach(function (day, index) {
index = formatWeekday(index);
if (index % 2) {
svg.append('text')
.attr('class', 'day-initial')
.attr('transform', 'translate(-8,' + (SQUARE_LENGTH + SQUARE_PADDING) * (index + 1) + ')')
.style('text-anchor', 'middle')
.attr('dy', '2')
.text(day);
}
});
}
function pluralizedTooltipUnit (count) {
if ('string' === typeof tooltipUnit) {
return (tooltipUnit + (count === 1 ? '' : 's'));
}
for (var i in tooltipUnit) {
var _rule = tooltipUnit[i];
var _min = _rule.min;
var _max = _rule.max || _rule.min;
_max = _max === 'Infinity' ? Infinity : _max;
if (count >= _min && count <= _max) {
return _rule.unit;
}
}
}
function tooltipHTMLForDate(d) {
var dateStr = moment(d).format('ddd, MMM Do YYYY');
var count = countForDate(d);
return '<span><strong>' + (count ? count : locale.No) + ' ' + pluralizedTooltipUnit(count) + '</strong> ' + locale.on + ' ' + dateStr + '</span>';
}
function countForDate(d) {
var key= moment(d).format( 'YYYY-MM-DD' );
return counterMap[key] || 0;
}
function formatWeekday(weekDay) {
if (weekStart === 1) {
if (weekDay === 0) {
return 6;
} else {
return weekDay - 1;
}
}
return weekDay;
}
var daysOfChart = chart.data().map(function (day) {
return day.date.toDateString();
});
}
return chart;
}
// polyfill for Array.find() method
/* jshint ignore:start */
if (!Array.prototype.find) {
Array.prototype.find = function (predicate) {
if (this === null) {
throw new TypeError('Array.prototype.find called on null or undefined');
}
if (typeof predicate !== 'function') {
throw new TypeError('predicate must be a function');
}
var list = Object(this);
var length = list.length >>> 0;
var thisArg = arguments[1];
var value;
for (var i = 0; i < length; i++) {
value = list[i];
if (predicate.call(thisArg, value, i, list)) {
return value;
}
}
return undefined;
};
}
/* jshint ignore:end */

2
public/vendor/plugins/d3/d3.v4.min.js vendored Normal file
View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

@@ -324,6 +324,13 @@ func mustEnableIssuesOrPulls(ctx *context.Context) {
}
}
func mustEnableUserHeatmap(ctx *context.Context) {
if !setting.Service.EnableUserHeatmap {
ctx.Status(404)
return
}
}
// RegisterRoutes registers all v1 APIs routes to web application.
// FIXME: custom form error response
func RegisterRoutes(m *macaron.Macaron) {
@@ -348,6 +355,7 @@ func RegisterRoutes(m *macaron.Macaron) {
m.Group("/:username", func() {
m.Get("", user.GetInfo)
m.Get("/heatmap", mustEnableUserHeatmap, user.GetUserHeatmapData)
m.Get("/repos", user.ListUserRepos)
m.Group("/tokens", func() {

View File

@@ -5,6 +5,7 @@
package swagger
import (
"code.gitea.io/gitea/models"
api "code.gitea.io/sdk/gitea"
)
@@ -34,3 +35,10 @@ type swaggerModelEditUserOption struct {
// in:body
Options api.EditUserOption
}
// UserHeatmapData
// swagger:response UserHeatmapData
type swaggerResponseUserHeatmapData struct {
// in:body
Body []models.UserHeatmapData `json:"body"`
}

View File

@@ -5,6 +5,7 @@
package user
import (
"net/http"
"strings"
"code.gitea.io/gitea/models"
@@ -133,3 +134,41 @@ func GetAuthenticatedUser(ctx *context.APIContext) {
// "$ref": "#/responses/User"
ctx.JSON(200, ctx.User.APIFormat())
}
// GetUserHeatmapData is the handler to get a users heatmap
func GetUserHeatmapData(ctx *context.APIContext) {
// swagger:operation GET /users/{username}/heatmap user userGetHeatmapData
// ---
// summary: Get a user's heatmap
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of user to get
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/UserHeatmapData"
// "404":
// "$ref": "#/responses/notFound"
// Get the user to throw an error if it does not exist
user, err := models.GetUserByName(ctx.Params(":username"))
if err != nil {
if models.IsErrUserNotExist(err) {
ctx.Status(http.StatusNotFound)
} else {
ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
}
return
}
heatmap, err := models.GetUserHeatmapDataByUser(user)
if err != nil {
ctx.Error(http.StatusInternalServerError, "GetUserHeatmapDataByUser", err)
return
}
ctx.JSON(200, heatmap)
}

View File

@@ -99,6 +99,8 @@ func Dashboard(ctx *context.Context) {
ctx.Data["PageIsDashboard"] = true
ctx.Data["PageIsNews"] = true
ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum
ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap
ctx.Data["HeatmapUser"] = ctxUser.Name
var err error
var mirrors []*models.Repository

View File

@@ -87,6 +87,8 @@ func Profile(ctx *context.Context) {
ctx.Data["PageIsUserProfile"] = true
ctx.Data["Owner"] = ctxUser
ctx.Data["OpenIDs"] = openIDs
ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap
ctx.Data["HeatmapUser"] = ctxUser.Name
showPrivate := ctx.IsSigned && (ctx.User.IsAdmin || ctx.User.ID == ctxUser.ID)
orgs, err := models.GetOrgsByUserID(ctxUser.ID, showPrivate)

View File

@@ -49,6 +49,28 @@
<script src="https://www.google.com/recaptcha/api.js" async></script>
{{end}}
{{end}}
{{if .EnableHeatmap}}
<script src="{{AppSubUrl}}/vendor/plugins/moment/moment.min.js" charset="utf-8"></script>
<script src="{{AppSubUrl}}/vendor/plugins/d3/d3.v4.min.js" charset="utf-8"></script>
<script src="{{AppSubUrl}}/vendor/plugins/calendar-heatmap/calendar-heatmap.js" charset="utf-8"></script>
<script type="text/javascript">
$.get( '{{AppSubUrl}}/api/v1/users/{{.HeatmapUser}}/heatmap', function( chartRawData ) {
var chartData = [];
for (var i = 0; i < chartRawData.length; i++) {
chartData[i] = {date: new Date(chartRawData[i].timestamp * 1000), count: chartRawData[i].contributions};
}
$('#loading-heatmap').removeClass('active');
var heatmap = calendarHeatmap()
.data(chartData)
.selector('#user-heatmap')
.colorRange(['#f4f4f4', '#459928'])
.tooltipEnabled(true);
heatmap();
});
</script>
{{end}}
{{if .RequireTribute}}
<script src="{{AppSubUrl}}/vendor/plugins/tribute/tribute.min.js"></script>

View File

@@ -100,6 +100,9 @@
{{end}}
{{if .RequireDropzone}}
<link rel="stylesheet" href="{{AppSubUrl}}/vendor/plugins/dropzone/dropzone.css">
{{end}}
{{if .EnableHeatmap}}
<link rel="stylesheet" href="{{AppSubUrl}}/vendor/plugins/calendar-heatmap/calendar-heatmap.css">
{{end}}
<style class="list-search-style"></style>

View File

@@ -5494,6 +5494,35 @@
}
}
},
"/users/{username}/heatmap": {
"get": {
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Get a user's heatmap",
"operationId": "userGetHeatmapData",
"parameters": [
{
"type": "string",
"description": "username of user to get",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/UserHeatmapData"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/users/{username}/keys": {
"get": {
"produces": [
@@ -7666,6 +7695,12 @@
},
"x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea"
},
"TimeStamp": {
"description": "TimeStamp defines a timestamp",
"type": "integer",
"format": "int64",
"x-go-package": "code.gitea.io/gitea/modules/util"
},
"TrackedTime": {
"description": "TrackedTime worked time for an issue / pr",
"type": "object",
@@ -7737,6 +7772,21 @@
},
"x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea"
},
"UserHeatmapData": {
"description": "UserHeatmapData represents the data needed to create a heatmap",
"type": "object",
"properties": {
"contributions": {
"type": "integer",
"format": "int64",
"x-go-name": "Contributions"
},
"timestamp": {
"$ref": "#/definitions/TimeStamp"
}
},
"x-go-package": "code.gitea.io/gitea/models"
},
"WatchInfo": {
"description": "WatchInfo represents an API watch status of one repository",
"type": "object",
@@ -8083,6 +8133,15 @@
"$ref": "#/definitions/User"
}
},
"UserHeatmapData": {
"description": "UserHeatmapData",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/UserHeatmapData"
}
}
},
"UserList": {
"description": "UserList",
"schema": {

View File

@@ -5,6 +5,11 @@
{{template "base/alert" .}}
<div class="ui mobile reversed stackable grid">
<div class="ten wide column">
{{if .EnableHeatmap}}
<div class="ui active centered inline indeterminate text loader" id="loading-heatmap">{{.i18n.Tr "user.heatmap.loading"}}</div>
<div id="user-heatmap"></div>
<div class="ui divider"></div>
{{end}}
{{template "user/dashboard/feeds" .}}
</div>
<div id="app" class="six wide column">

View File

@@ -95,6 +95,11 @@
</div>
{{if eq .TabName "activity"}}
{{if .EnableHeatmap}}
<div class="ui active centered inline indeterminate text loader" id="loading-heatmap">{{.i18n.Tr "user.heatmap.loading"}}</div>
<div id="user-heatmap"></div>
<div class="ui divider"></div>
{{end}}
<div class="feeds">
{{template "user/dashboard/feeds" .}}
</div>