* 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 statementtags/v1.7.0-dev
@@ -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`) | |||
@@ -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 | |||
repo_id: 2 | |||
is_private: true | |||
created_unix: 1540139562 | |||
- | |||
id: 2 |
@@ -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) |
@@ -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 | |||
} |
@@ -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 | |||
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) |
@@ -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. |
@@ -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; | |||
} | |||
} | |||
} |
@@ -58,6 +58,10 @@ | |||
.ui.repository.list { | |||
margin-top: 25px; | |||
} | |||
#loading-heatmap{ | |||
margin-bottom: 1em; | |||
} | |||
} | |||
&.followers { |
@@ -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 |
@@ -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> |
@@ -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; | |||
} |
@@ -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 */ |
@@ -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() { |
@@ -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"` | |||
} |
@@ -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) | |||
} |
@@ -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 |
@@ -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) |
@@ -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> | |||
@@ -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> | |||
@@ -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": { |
@@ -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"> |
@@ -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> |