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 statementtokarchuk/v1.17
parent
f38fce916e
commit
6759237eda
@ -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) |
||||
} |
@ -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)) |
||||
} |
File diff suppressed because one or more lines are too long
@ -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 */ |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in new issue