forked from forgejo/forgejo
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
9
public/vendor/VERSIONS
vendored
9
public/vendor/VERSIONS
vendored
|
@ -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
|
||||
|
|
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/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>
|
||||
|
|
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
Loading…
Add table
Add a link
Reference in a new issue