diff options
author | Norwin <noerw@users.noreply.github.com> | 2021-01-21 14:51:52 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-01-21 15:51:52 +0100 |
commit | b5570d3e680570343c1552bfc972b19b161209cd (patch) | |
tree | 548fbcdb9a760b47c54ac931c0180d87d08029d5 | |
parent | 56a89296050096df29d0a653019c194631cc6562 (diff) | |
download | gitea-b5570d3e680570343c1552bfc972b19b161209cd.tar.gz gitea-b5570d3e680570343c1552bfc972b19b161209cd.zip |
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
-rw-r--r-- | integrations/api_issue_stopwatch_test.go | 14 | ||||
-rw-r--r-- | integrations/attachment_test.go | 2 | ||||
-rw-r--r-- | models/issue_stopwatch.go | 10 | ||||
-rw-r--r-- | modules/convert/issue.go | 2 | ||||
-rw-r--r-- | modules/structs/issue_stopwatch.go | 2 | ||||
-rw-r--r-- | options/locale/locale_en-US.ini | 9 | ||||
-rw-r--r-- | package-lock.json | 13 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | routers/repo/issue_stopwatch.go | 45 | ||||
-rw-r--r-- | routers/routes/macaron.go | 1 | ||||
-rw-r--r-- | templates/base/head_navbar.tmpl | 38 | ||||
-rw-r--r-- | templates/repo/issue/new_form.tmpl | 2 | ||||
-rw-r--r-- | templates/swagger/v1_json.tmpl | 9 | ||||
-rw-r--r-- | web_src/js/features/stopwatch.js | 91 | ||||
-rw-r--r-- | web_src/js/index.js | 2 |
15 files changed, 226 insertions, 15 deletions
diff --git a/integrations/api_issue_stopwatch_test.go b/integrations/api_issue_stopwatch_test.go index 39b9b97411..c0b8fd9c69 100644 --- a/integrations/api_issue_stopwatch_test.go +++ b/integrations/api_issue_stopwatch_test.go @@ -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)) } } diff --git a/integrations/attachment_test.go b/integrations/attachment_test.go index dd734145d2..a28e38b990 100644 --- a/integrations/attachment_test.go +++ b/integrations/attachment_test.go @@ -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{ diff --git a/models/issue_stopwatch.go b/models/issue_stopwatch.go index 4b2bf1505d..a1c88503d8 100644 --- a/models/issue_stopwatch.go +++ b/models/issue_stopwatch.go @@ -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. diff --git a/modules/convert/issue.go b/modules/convert/issue.go index 36446da2d1..b773e78a6b 100644 --- a/modules/convert/issue.go +++ b/modules/convert/issue.go @@ -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, diff --git a/modules/structs/issue_stopwatch.go b/modules/structs/issue_stopwatch.go index 8599e07273..15d17cdda7 100644 --- a/modules/structs/issue_stopwatch.go +++ b/modules/structs/issue_stopwatch.go @@ -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"` diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index c950591d76..30fa5f8a73 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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 diff --git a/package-lock.json b/package-lock.json index e20eaf4a3b..f3ba4a8177 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2abdc5ab7e..8252376643 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/routers/repo/issue_stopwatch.go b/routers/repo/issue_stopwatch.go index 28105dfe03..b8efb3b841 100644 --- a/routers/repo/issue_stopwatch.go +++ b/routers/repo/issue_stopwatch.go @@ -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 +} diff --git a/routers/routes/macaron.go b/routers/routes/macaron.go index 34978724a8..f64a0a597b 100644 --- a/routers/routes/macaron.go +++ b/routers/routes/macaron.go @@ -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() diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index a2b4d4f1d9..efab76f33c 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -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> diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index efebc3bf36..f208416261 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -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"> diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e46d37173b..0f26943da1 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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" diff --git a/web_src/js/features/stopwatch.js b/web_src/js/features/stopwatch.js new file mode 100644 index 0000000000..d500fb5f0f --- /dev/null +++ b/web_src/js/features/stopwatch.js @@ -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); +} diff --git a/web_src/js/index.js b/web_src/js/index.js index 541f32507d..9a35507bab 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -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(), ]); |