* 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> | |||