mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Display current stopwatch in navbar (#14122)
* add notification about running stopwatch to header * serialize seconds, duration in stopwatches api * ajax update stopwatch i should get my testenv working locally... * new variant: hover dialog * noscript compatibility * js: live-update stopwatch time * js live update robustness
This commit is contained in:
		| @@ -7,7 +7,6 @@ package integrations | ||||
| import ( | ||||
| 	"net/http" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models" | ||||
| 	api "code.gitea.io/gitea/modules/structs" | ||||
| @@ -31,14 +30,11 @@ func TestAPIListStopWatches(t *testing.T) { | ||||
| 	issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: stopwatch.IssueID}).(*models.Issue) | ||||
| 	if assert.Len(t, apiWatches, 1) { | ||||
| 		assert.EqualValues(t, stopwatch.CreatedUnix.AsTime().Unix(), apiWatches[0].Created.Unix()) | ||||
| 		apiWatches[0].Created = time.Time{} | ||||
| 		assert.EqualValues(t, api.StopWatch{ | ||||
| 			Created:       time.Time{}, | ||||
| 			IssueIndex:    issue.Index, | ||||
| 			IssueTitle:    issue.Title, | ||||
| 			RepoName:      repo.Name, | ||||
| 			RepoOwnerName: repo.OwnerName, | ||||
| 		}, *apiWatches[0]) | ||||
| 		assert.EqualValues(t, issue.Index, apiWatches[0].IssueIndex) | ||||
| 		assert.EqualValues(t, issue.Title, apiWatches[0].IssueTitle) | ||||
| 		assert.EqualValues(t, repo.Name, apiWatches[0].RepoName) | ||||
| 		assert.EqualValues(t, repo.OwnerName, apiWatches[0].RepoOwnerName) | ||||
| 		assert.Greater(t, int64(apiWatches[0].Seconds), int64(0)) | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -72,7 +72,7 @@ func TestCreateIssueAttachment(t *testing.T) { | ||||
| 	resp := session.MakeRequest(t, req, http.StatusOK) | ||||
| 	htmlDoc := NewHTMLParser(t, resp.Body) | ||||
|  | ||||
| 	link, exists := htmlDoc.doc.Find("form").Attr("action") | ||||
| 	link, exists := htmlDoc.doc.Find("form#new-issue").Attr("action") | ||||
| 	assert.True(t, exists, "The template has changed") | ||||
|  | ||||
| 	postData := map[string]string{ | ||||
|   | ||||
| @@ -19,6 +19,16 @@ type Stopwatch struct { | ||||
| 	CreatedUnix timeutil.TimeStamp `xorm:"created"` | ||||
| } | ||||
|  | ||||
| // Seconds returns the amount of time passed since creation, based on local server time | ||||
| func (s Stopwatch) Seconds() int64 { | ||||
| 	return int64(timeutil.TimeStampNow() - s.CreatedUnix) | ||||
| } | ||||
|  | ||||
| // Duration returns a human-readable duration string based on local server time | ||||
| func (s Stopwatch) Duration() string { | ||||
| 	return SecToTime(s.Seconds()) | ||||
| } | ||||
|  | ||||
| func getStopwatch(e Engine, userID, issueID int64) (sw *Stopwatch, exists bool, err error) { | ||||
| 	sw = new(Stopwatch) | ||||
| 	exists, err = e. | ||||
|   | ||||
| @@ -147,6 +147,8 @@ func ToStopWatches(sws []*models.Stopwatch) (api.StopWatches, error) { | ||||
|  | ||||
| 		result = append(result, api.StopWatch{ | ||||
| 			Created:       sw.CreatedUnix.AsTime(), | ||||
| 			Seconds:       sw.Seconds(), | ||||
| 			Duration:      sw.Duration(), | ||||
| 			IssueIndex:    issue.Index, | ||||
| 			IssueTitle:    issue.Title, | ||||
| 			RepoOwnerName: repo.OwnerName, | ||||
|   | ||||
| @@ -12,6 +12,8 @@ import ( | ||||
| type StopWatch struct { | ||||
| 	// swagger:strfmt date-time | ||||
| 	Created       time.Time `json:"created"` | ||||
| 	Seconds       int64     `json:"seconds"` | ||||
| 	Duration      string    `json:"duration"` | ||||
| 	IssueIndex    int64     `json:"issue_index"` | ||||
| 	IssueTitle    string    `json:"issue_title"` | ||||
| 	RepoOwnerName string    `json:"repo_owner_name"` | ||||
|   | ||||
| @@ -15,6 +15,7 @@ page = Page | ||||
| template = Template | ||||
| language = Language | ||||
| notifications = Notifications | ||||
| active_stopwatch = Active Time Tracker | ||||
| create_new = Create… | ||||
| user_profile_and_more = Profile and Settings… | ||||
| signed_in_as = Signed in as | ||||
| @@ -1139,13 +1140,15 @@ issues.lock.title = Lock conversation on this issue. | ||||
| issues.unlock.title = Unlock conversation on this issue. | ||||
| issues.comment_on_locked = You cannot comment on a locked issue. | ||||
| issues.tracker = Time Tracker | ||||
| issues.start_tracking_short = Start | ||||
| issues.start_tracking_short = Start Timer | ||||
| issues.start_tracking = Start Time Tracking | ||||
| issues.start_tracking_history = `started working %s` | ||||
| issues.tracker_auto_close = Timer will be stopped automatically when this issue gets closed | ||||
| issues.tracking_already_started = `You have already started time tracking on <a href="%s">another issue</a>!` | ||||
| issues.stop_tracking = Stop | ||||
| issues.stop_tracking = Stop Timer | ||||
| issues.stop_tracking_history = `stopped working %s` | ||||
| issues.cancel_tracking = Discard | ||||
| issues.cancel_tracking_history = `cancelled time tracking %s` | ||||
| issues.add_time = Manually Add Time | ||||
| issues.add_time_short = Add Time | ||||
| issues.add_time_cancel = Cancel | ||||
| @@ -1154,8 +1157,6 @@ issues.del_time_history= `deleted spent time %s` | ||||
| issues.add_time_hours = Hours | ||||
| issues.add_time_minutes = Minutes | ||||
| issues.add_time_sum_to_small = No time was entered. | ||||
| issues.cancel_tracking = Cancel | ||||
| issues.cancel_tracking_history = `cancelled time tracking %s` | ||||
| issues.time_spent_total = Total Time Spent | ||||
| issues.time_spent_from_all_authors = `Total Time Spent: %s` | ||||
| issues.due_date = Due Date | ||||
|   | ||||
							
								
								
									
										13
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -5293,6 +5293,11 @@ | ||||
|         "json-parse-better-errors": "^1.0.1" | ||||
|       } | ||||
|     }, | ||||
|     "parse-ms": { | ||||
|       "version": "2.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", | ||||
|       "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==" | ||||
|     }, | ||||
|     "parse-node-version": { | ||||
|       "version": "1.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", | ||||
| @@ -6702,6 +6707,14 @@ | ||||
|       "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", | ||||
|       "optional": true | ||||
|     }, | ||||
|     "pretty-ms": { | ||||
|       "version": "7.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", | ||||
|       "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", | ||||
|       "requires": { | ||||
|         "parse-ms": "^2.1.0" | ||||
|       } | ||||
|     }, | ||||
|     "progress": { | ||||
|       "version": "2.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", | ||||
|   | ||||
| @@ -34,6 +34,7 @@ | ||||
|     "monaco-editor": "0.21.2", | ||||
|     "monaco-editor-webpack-plugin": "2.1.0", | ||||
|     "postcss": "8.2.1", | ||||
|     "pretty-ms": "7.0.1", | ||||
|     "raw-loader": "4.0.2", | ||||
|     "sortablejs": "1.12.0", | ||||
|     "swagger-ui-dist": "3.38.0", | ||||
|   | ||||
| @@ -6,6 +6,7 @@ package repo | ||||
|  | ||||
| import ( | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models" | ||||
| 	"code.gitea.io/gitea/modules/context" | ||||
| @@ -61,3 +62,47 @@ func CancelStopwatch(c *context.Context) { | ||||
| 	url := issue.HTMLURL() | ||||
| 	c.Redirect(url, http.StatusSeeOther) | ||||
| } | ||||
|  | ||||
| // GetActiveStopwatch is the middleware that sets .ActiveStopwatch on context | ||||
| func GetActiveStopwatch(c *context.Context) { | ||||
| 	if strings.HasPrefix(c.Req.URL.Path, "/api") { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if !c.IsSigned { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	_, sw, err := models.HasUserStopwatch(c.User.ID) | ||||
| 	if err != nil { | ||||
| 		c.ServerError("HasUserStopwatch", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if sw == nil || sw.ID == 0 { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	issue, err := models.GetIssueByID(sw.IssueID) | ||||
| 	if err != nil || issue == nil { | ||||
| 		c.ServerError("GetIssueByID", err) | ||||
| 		return | ||||
| 	} | ||||
| 	if err = issue.LoadRepo(); err != nil { | ||||
| 		c.ServerError("LoadRepo", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Data["ActiveStopwatch"] = StopwatchTmplInfo{ | ||||
| 		issue.Repo.FullName(), | ||||
| 		issue.Index, | ||||
| 		sw.Seconds() + 1, // ensure time is never zero in ui | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // StopwatchTmplInfo is a view on a stopwatch specifically for template rendering | ||||
| type StopwatchTmplInfo struct { | ||||
| 	RepoSlug   string | ||||
| 	IssueIndex int64 | ||||
| 	Seconds    int64 | ||||
| } | ||||
|   | ||||
| @@ -176,6 +176,7 @@ func RegisterMacaronRoutes(m *macaron.Macaron) { | ||||
| 	} | ||||
|  | ||||
| 	m.Use(user.GetNotificationCount) | ||||
| 	m.Use(repo.GetActiveStopwatch) | ||||
| 	m.Use(func(ctx *context.Context) { | ||||
| 		ctx.Data["UnitWikiGlobalDisabled"] = models.UnitTypeWiki.UnitGlobalDisabled() | ||||
| 		ctx.Data["UnitIssuesGlobalDisabled"] = models.UnitTypeIssues.UnitGlobalDisabled() | ||||
|   | ||||
| @@ -67,6 +67,44 @@ | ||||
| 		</div> | ||||
| 	{{else if .IsSigned}} | ||||
| 		<div class="right stackable menu"> | ||||
| 			{{$issueURL := Printf "%s/%s/issues/%d" AppSubUrl .ActiveStopwatch.RepoSlug .ActiveStopwatch.IssueIndex}} | ||||
| 			<a class="active-stopwatch-trigger item ui label {{if not .ActiveStopwatch}}hidden{{end}}" href="{{$issueURL}}"> | ||||
| 				<span class="text"> | ||||
| 					<span class="fitted item"> | ||||
| 						{{svg "octicon-stopwatch"}} | ||||
| 						<span class="red" style="position:absolute; right:-0.6em; top:-0.6em;">{{svg "octicon-dot-fill"}}</span> | ||||
| 					</span> | ||||
| 					<span class="sr-mobile-only">{{.i18n.Tr "active_stopwatch"}}</span> | ||||
| 				</span> | ||||
| 			</a> | ||||
| 			<div class="ui popup very wide"> | ||||
| 				<div class="df ac"> | ||||
| 					<a class="stopwatch-link df ac" href="{{$issueURL}}"> | ||||
| 						{{svg "octicon-issue-opened"}} | ||||
| 						<span class="stopwatch-issue">{{.ActiveStopwatch.RepoSlug}}#{{.ActiveStopwatch.IssueIndex}}</span> | ||||
| 						<span class="ui label blue stopwatch-time my-0 mx-4" data-seconds="{{.ActiveStopwatch.Seconds}}"> | ||||
| 							{{if .ActiveStopwatch}}{{Sec2Time .ActiveStopwatch.Seconds}}{{end}} | ||||
| 						</span> | ||||
| 					</a> | ||||
| 					<form class="stopwatch-commit" method="POST" action="{{$issueURL}}/times/stopwatch/toggle"> | ||||
| 						{{.CsrfTokenHtml}} | ||||
| 						<button | ||||
| 							class="ui button mini compact basic icon fitted poping up" | ||||
| 							data-content="{{.i18n.Tr "repo.issues.stop_tracking"}}" | ||||
| 							data-position="top right" data-variation="small inverted" | ||||
| 						>{{svg "octicon-square-fill"}}</button> | ||||
| 					</form> | ||||
| 					<form class="stopwatch-cancel" method="POST" action="{{$issueURL}}/times/stopwatch/cancel"> | ||||
| 						{{.CsrfTokenHtml}} | ||||
| 						<button | ||||
| 							class="ui button mini compact basic icon fitted poping up" | ||||
| 							data-content="{{.i18n.Tr "repo.issues.cancel_tracking"}}" | ||||
| 							data-position="top right" data-variation="small inverted" | ||||
| 						>{{svg "octicon-trashcan"}}</button> | ||||
| 					</form> | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 			<a href="{{AppSubUrl}}/notifications" class="item poping up" data-content='{{.i18n.Tr "notifications"}}' data-variation="tiny inverted"> | ||||
| 				<span class="text"> | ||||
| 					<span class="fitted">{{svg "octicon-bell"}}</span> | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| <form class="ui comment form stackable grid" action="{{.Link}}" method="post"> | ||||
| <form class="ui comment form stackable grid" id="new-issue" action="{{.Link}}" method="post"> | ||||
| 	{{.CsrfTokenHtml}} | ||||
| 	{{if .Flash}} | ||||
| 		<div class="sixteen wide column"> | ||||
|   | ||||
| @@ -15473,6 +15473,10 @@ | ||||
|           "format": "date-time", | ||||
|           "x-go-name": "Created" | ||||
|         }, | ||||
|         "duration": { | ||||
|           "type": "string", | ||||
|           "x-go-name": "Duration" | ||||
|         }, | ||||
|         "issue_index": { | ||||
|           "type": "integer", | ||||
|           "format": "int64", | ||||
| @@ -15489,6 +15493,11 @@ | ||||
|         "repo_owner_name": { | ||||
|           "type": "string", | ||||
|           "x-go-name": "RepoOwnerName" | ||||
|         }, | ||||
|         "seconds": { | ||||
|           "type": "integer", | ||||
|           "format": "int64", | ||||
|           "x-go-name": "Seconds" | ||||
|         } | ||||
|       }, | ||||
|       "x-go-package": "code.gitea.io/gitea/modules/structs" | ||||
|   | ||||
							
								
								
									
										91
									
								
								web_src/js/features/stopwatch.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								web_src/js/features/stopwatch.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| import prettyMilliseconds from 'pretty-ms'; | ||||
| const {AppSubUrl, csrf, NotificationSettings} = window.config; | ||||
|  | ||||
| let updateTimeInterval = null; // holds setInterval id when active | ||||
|  | ||||
| export async function initStopwatch() { | ||||
|   const stopwatchEl = $('.active-stopwatch-trigger'); | ||||
|  | ||||
|   stopwatchEl.removeAttr('href'); // intended for noscript mode only | ||||
|   stopwatchEl.popup({ | ||||
|     position: 'bottom right', | ||||
|     hoverable: true, | ||||
|   }); | ||||
|  | ||||
|   // form handlers | ||||
|   $('form > button', stopwatchEl).on('click', function () { | ||||
|     $(this).parent().trigger('submit'); | ||||
|   }); | ||||
|  | ||||
|   if (!stopwatchEl || NotificationSettings.MinTimeout <= 0) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   const fn = (timeout) => { | ||||
|     setTimeout(async () => { | ||||
|       await updateStopwatchWithCallback(fn, timeout); | ||||
|     }, timeout); | ||||
|   }; | ||||
|  | ||||
|   fn(NotificationSettings.MinTimeout); | ||||
|  | ||||
|   const currSeconds = $('.stopwatch-time').data('seconds'); | ||||
|   if (currSeconds) { | ||||
|     updateTimeInterval = updateStopwatchTime(currSeconds); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function updateStopwatchWithCallback(callback, timeout) { | ||||
|   const isSet = await updateStopwatch(); | ||||
|  | ||||
|   if (!isSet) { | ||||
|     timeout = NotificationSettings.MinTimeout; | ||||
|   } else if (timeout < NotificationSettings.MaxTimeout) { | ||||
|     timeout += NotificationSettings.TimeoutStep; | ||||
|   } | ||||
|  | ||||
|   callback(timeout); | ||||
| } | ||||
|  | ||||
| async function updateStopwatch() { | ||||
|   const data = await $.ajax({ | ||||
|     type: 'GET', | ||||
|     url: `${AppSubUrl}/api/v1/user/stopwatches`, | ||||
|     headers: {'X-Csrf-Token': csrf}, | ||||
|   }); | ||||
|  | ||||
|   if (updateTimeInterval) { | ||||
|     clearInterval(updateTimeInterval); | ||||
|     updateTimeInterval = null; | ||||
|   } | ||||
|  | ||||
|   const watch = data[0]; | ||||
|   const btnEl = $('.active-stopwatch-trigger'); | ||||
|   if (!watch) { | ||||
|     btnEl.addClass('hidden'); | ||||
|   } else { | ||||
|     const {repo_owner_name, repo_name, issue_index, seconds} = watch; | ||||
|     const issueUrl = `${AppSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`; | ||||
|     $('.stopwatch-link').attr('href', issueUrl); | ||||
|     $('.stopwatch-commit').attr('action', `${issueUrl}/times/stopwatch/toggle`); | ||||
|     $('.stopwatch-cancel').attr('action', `${issueUrl}/times/stopwatch/cancel`); | ||||
|     $('.stopwatch-issue').text(`${repo_owner_name}/${repo_name}#${issue_index}`); | ||||
|     $('.stopwatch-time').text(prettyMilliseconds(seconds * 1000)); | ||||
|     updateStopwatchTime(seconds); | ||||
|     btnEl.removeClass('hidden'); | ||||
|   } | ||||
|  | ||||
|   return !!data.length; | ||||
| } | ||||
|  | ||||
| async function updateStopwatchTime(seconds) { | ||||
|   const secs = parseInt(seconds); | ||||
|   if (!Number.isFinite(secs)) return; | ||||
|  | ||||
|   const start = Date.now(); | ||||
|   updateTimeInterval = setInterval(() => { | ||||
|     const delta = Date.now() - start; | ||||
|     const dur = prettyMilliseconds(secs * 1000 + delta, {compact: true}); | ||||
|     $('.stopwatch-time').text(dur); | ||||
|   }, 1000); | ||||
| } | ||||
| @@ -22,6 +22,7 @@ import createDropzone from './features/dropzone.js'; | ||||
| import initTableSort from './features/tablesort.js'; | ||||
| import ActivityTopAuthors from './components/ActivityTopAuthors.vue'; | ||||
| import {initNotificationsTable, initNotificationCount} from './features/notification.js'; | ||||
| import {initStopwatch} from './features/stopwatch.js'; | ||||
| import {createCodeEditor, createMonaco} from './features/codeeditor.js'; | ||||
| import {svg, svgs} from './svg.js'; | ||||
| import {stripTags} from './utils.js'; | ||||
| @@ -2626,6 +2627,7 @@ $(document).ready(async () => { | ||||
|     initProject(), | ||||
|     initServiceWorker(), | ||||
|     initNotificationCount(), | ||||
|     initStopwatch(), | ||||
|     renderMarkdownContent(), | ||||
|     initGithook(), | ||||
|   ]); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user