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:
parent
f38fce916e
commit
6759237eda
27 changed files with 649 additions and 1 deletions
|
@ -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_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.
|
- `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.
|
- `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`)
|
## Webhook (`webhook`)
|
||||||
|
|
||||||
|
|
30
integrations/api_user_heatmap_test.go
Normal file
30
integrations/api_user_heatmap_test.go
Normal 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)
|
||||||
|
}
|
|
@ -5,6 +5,7 @@
|
||||||
act_user_id: 2
|
act_user_id: 2
|
||||||
repo_id: 2
|
repo_id: 2
|
||||||
is_private: true
|
is_private: true
|
||||||
|
created_unix: 1540139562
|
||||||
|
|
||||||
-
|
-
|
||||||
id: 2
|
id: 2
|
||||||
|
|
|
@ -48,6 +48,7 @@ func MainTest(m *testing.M, pathToGiteaRoot string) {
|
||||||
setting.RunUser = "runuser"
|
setting.RunUser = "runuser"
|
||||||
setting.SSH.Port = 3000
|
setting.SSH.Port = 3000
|
||||||
setting.SSH.Domain = "try.gitea.io"
|
setting.SSH.Domain = "try.gitea.io"
|
||||||
|
setting.UseSQLite3 = true
|
||||||
setting.RepoRootPath, err = ioutil.TempDir(os.TempDir(), "repos")
|
setting.RepoRootPath, err = ioutil.TempDir(os.TempDir(), "repos")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalTestError("TempDir: %v\n", err)
|
fatalTestError("TempDir: %v\n", err)
|
||||||
|
|
40
models/user_heatmap.go
Normal file
40
models/user_heatmap.go
Normal 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
|
||||||
|
}
|
33
models/user_heatmap_test.go
Normal file
33
models/user_heatmap_test.go
Normal 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))
|
||||||
|
}
|
|
@ -1218,6 +1218,7 @@ var Service struct {
|
||||||
DefaultEnableDependencies bool
|
DefaultEnableDependencies bool
|
||||||
DefaultAllowOnlyContributorsToTrackTime bool
|
DefaultAllowOnlyContributorsToTrackTime bool
|
||||||
NoReplyAddress string
|
NoReplyAddress string
|
||||||
|
EnableUserHeatmap bool
|
||||||
|
|
||||||
// OpenID settings
|
// OpenID settings
|
||||||
EnableOpenIDSignIn bool
|
EnableOpenIDSignIn bool
|
||||||
|
@ -1249,6 +1250,7 @@ func newService() {
|
||||||
Service.DefaultEnableDependencies = sec.Key("DEFAULT_ENABLE_DEPENDENCIES").MustBool(true)
|
Service.DefaultEnableDependencies = sec.Key("DEFAULT_ENABLE_DEPENDENCIES").MustBool(true)
|
||||||
Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").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.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply.example.org")
|
||||||
|
Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true)
|
||||||
|
|
||||||
sec = Cfg.Section("openid")
|
sec = Cfg.Section("openid")
|
||||||
Service.EnableOpenIDSignIn = sec.Key("ENABLE_OPENID_SIGNIN").MustBool(!InstallLock)
|
Service.EnableOpenIDSignIn = sec.Key("ENABLE_OPENID_SIGNIN").MustBool(!InstallLock)
|
||||||
|
|
|
@ -320,6 +320,7 @@ starred = Starred Repositories
|
||||||
following = Following
|
following = Following
|
||||||
follow = Follow
|
follow = Follow
|
||||||
unfollow = Unfollow
|
unfollow = Unfollow
|
||||||
|
heatmap.loading = Loading Heatmap…
|
||||||
|
|
||||||
form.name_reserved = The username '%s' is reserved.
|
form.name_reserved = The username '%s' is reserved.
|
||||||
form.name_pattern_not_allowed = The pattern '%s' is not allowed in a username.
|
form.name_pattern_not_allowed = The pattern '%s' is not allowed in a username.
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -588,3 +588,20 @@ footer {
|
||||||
border-bottom-width: 0 !important;
|
border-bottom-width: 0 !important;
|
||||||
margin-bottom: 2px !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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -58,6 +58,10 @@
|
||||||
.ui.repository.list {
|
.ui.repository.list {
|
||||||
margin-top: 25px;
|
margin-top: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#loading-heatmap{
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.followers {
|
&.followers {
|
||||||
|
|
9
public/vendor/VERSIONS
vendored
9
public/vendor/VERSIONS
vendored
|
@ -58,3 +58,12 @@ Version: 4.3.0
|
||||||
|
|
||||||
File(s): /vendor/assets/swagger-ui/
|
File(s): /vendor/assets/swagger-ui/
|
||||||
Version: 3.0.4
|
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
|
||||||
|
|
15
public/vendor/librejs.html
vendored
15
public/vendor/librejs.html
vendored
|
@ -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/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>
|
<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>
|
||||||
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
|
|
27
public/vendor/plugins/calendar-heatmap/calendar-heatmap.css
vendored
Normal file
27
public/vendor/plugins/calendar-heatmap/calendar-heatmap.css
vendored
Normal 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;
|
||||||
|
}
|
311
public/vendor/plugins/calendar-heatmap/calendar-heatmap.js
vendored
Normal file
311
public/vendor/plugins/calendar-heatmap/calendar-heatmap.js
vendored
Normal 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
2
public/vendor/plugins/d3/d3.v4.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/vendor/plugins/moment/moment.min.js
vendored
Normal file
1
public/vendor/plugins/moment/moment.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -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.
|
// RegisterRoutes registers all v1 APIs routes to web application.
|
||||||
// FIXME: custom form error response
|
// FIXME: custom form error response
|
||||||
func RegisterRoutes(m *macaron.Macaron) {
|
func RegisterRoutes(m *macaron.Macaron) {
|
||||||
|
@ -348,6 +355,7 @@ func RegisterRoutes(m *macaron.Macaron) {
|
||||||
|
|
||||||
m.Group("/:username", func() {
|
m.Group("/:username", func() {
|
||||||
m.Get("", user.GetInfo)
|
m.Get("", user.GetInfo)
|
||||||
|
m.Get("/heatmap", mustEnableUserHeatmap, user.GetUserHeatmapData)
|
||||||
|
|
||||||
m.Get("/repos", user.ListUserRepos)
|
m.Get("/repos", user.ListUserRepos)
|
||||||
m.Group("/tokens", func() {
|
m.Group("/tokens", func() {
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
package swagger
|
package swagger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
api "code.gitea.io/sdk/gitea"
|
api "code.gitea.io/sdk/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -34,3 +35,10 @@ type swaggerModelEditUserOption struct {
|
||||||
// in:body
|
// in:body
|
||||||
Options api.EditUserOption
|
Options api.EditUserOption
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserHeatmapData
|
||||||
|
// swagger:response UserHeatmapData
|
||||||
|
type swaggerResponseUserHeatmapData struct {
|
||||||
|
// in:body
|
||||||
|
Body []models.UserHeatmapData `json:"body"`
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models"
|
"code.gitea.io/gitea/models"
|
||||||
|
@ -133,3 +134,41 @@ func GetAuthenticatedUser(ctx *context.APIContext) {
|
||||||
// "$ref": "#/responses/User"
|
// "$ref": "#/responses/User"
|
||||||
ctx.JSON(200, ctx.User.APIFormat())
|
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)
|
||||||
|
}
|
||||||
|
|
|
@ -99,6 +99,8 @@ func Dashboard(ctx *context.Context) {
|
||||||
ctx.Data["PageIsDashboard"] = true
|
ctx.Data["PageIsDashboard"] = true
|
||||||
ctx.Data["PageIsNews"] = true
|
ctx.Data["PageIsNews"] = true
|
||||||
ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum
|
ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum
|
||||||
|
ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap
|
||||||
|
ctx.Data["HeatmapUser"] = ctxUser.Name
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
var mirrors []*models.Repository
|
var mirrors []*models.Repository
|
||||||
|
|
|
@ -87,6 +87,8 @@ func Profile(ctx *context.Context) {
|
||||||
ctx.Data["PageIsUserProfile"] = true
|
ctx.Data["PageIsUserProfile"] = true
|
||||||
ctx.Data["Owner"] = ctxUser
|
ctx.Data["Owner"] = ctxUser
|
||||||
ctx.Data["OpenIDs"] = openIDs
|
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)
|
showPrivate := ctx.IsSigned && (ctx.User.IsAdmin || ctx.User.ID == ctxUser.ID)
|
||||||
|
|
||||||
orgs, err := models.GetOrgsByUserID(ctxUser.ID, showPrivate)
|
orgs, err := models.GetOrgsByUserID(ctxUser.ID, showPrivate)
|
||||||
|
|
|
@ -49,6 +49,28 @@
|
||||||
<script src="https://www.google.com/recaptcha/api.js" async></script>
|
<script src="https://www.google.com/recaptcha/api.js" async></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{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}}
|
{{if .RequireTribute}}
|
||||||
<script src="{{AppSubUrl}}/vendor/plugins/tribute/tribute.min.js"></script>
|
<script src="{{AppSubUrl}}/vendor/plugins/tribute/tribute.min.js"></script>
|
||||||
|
|
||||||
|
|
|
@ -100,6 +100,9 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .RequireDropzone}}
|
{{if .RequireDropzone}}
|
||||||
<link rel="stylesheet" href="{{AppSubUrl}}/vendor/plugins/dropzone/dropzone.css">
|
<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}}
|
{{end}}
|
||||||
<style class="list-search-style"></style>
|
<style class="list-search-style"></style>
|
||||||
|
|
||||||
|
|
|
@ -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": {
|
"/users/{username}/keys": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
@ -7666,6 +7695,12 @@
|
||||||
},
|
},
|
||||||
"x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea"
|
"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": {
|
"TrackedTime": {
|
||||||
"description": "TrackedTime worked time for an issue / pr",
|
"description": "TrackedTime worked time for an issue / pr",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
@ -7737,6 +7772,21 @@
|
||||||
},
|
},
|
||||||
"x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea"
|
"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": {
|
"WatchInfo": {
|
||||||
"description": "WatchInfo represents an API watch status of one repository",
|
"description": "WatchInfo represents an API watch status of one repository",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
@ -8083,6 +8133,15 @@
|
||||||
"$ref": "#/definitions/User"
|
"$ref": "#/definitions/User"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"UserHeatmapData": {
|
||||||
|
"description": "UserHeatmapData",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/UserHeatmapData"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UserList": {
|
"UserList": {
|
||||||
"description": "UserList",
|
"description": "UserList",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|
|
@ -5,6 +5,11 @@
|
||||||
{{template "base/alert" .}}
|
{{template "base/alert" .}}
|
||||||
<div class="ui mobile reversed stackable grid">
|
<div class="ui mobile reversed stackable grid">
|
||||||
<div class="ten wide column">
|
<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" .}}
|
{{template "user/dashboard/feeds" .}}
|
||||||
</div>
|
</div>
|
||||||
<div id="app" class="six wide column">
|
<div id="app" class="six wide column">
|
||||||
|
|
|
@ -95,6 +95,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if eq .TabName "activity"}}
|
{{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">
|
<div class="feeds">
|
||||||
{{template "user/dashboard/feeds" .}}
|
{{template "user/dashboard/feeds" .}}
|
||||||
</div>
|
</div>
|
||||||
|
|
Reference in a new issue