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_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`) | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										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 | ||||
|   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) | ||||
|  |  | |||
							
								
								
									
										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 | ||||
| 	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. | ||||
|  |  | |||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -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 { | ||||
|  |  | |||
							
								
								
									
										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
											
										
									
								
							|  | @ -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> | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue