]> source.dussan.org Git - gitea.git/commitdiff
Display current stopwatch in navbar (#14122)
authorNorwin <noerw@users.noreply.github.com>
Thu, 21 Jan 2021 14:51:52 +0000 (14:51 +0000)
committerGitHub <noreply@github.com>
Thu, 21 Jan 2021 14:51:52 +0000 (15:51 +0100)
* 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

15 files changed:
integrations/api_issue_stopwatch_test.go
integrations/attachment_test.go
models/issue_stopwatch.go
modules/convert/issue.go
modules/structs/issue_stopwatch.go
options/locale/locale_en-US.ini
package-lock.json
package.json
routers/repo/issue_stopwatch.go
routers/routes/macaron.go
templates/base/head_navbar.tmpl
templates/repo/issue/new_form.tmpl
templates/swagger/v1_json.tmpl
web_src/js/features/stopwatch.js [new file with mode: 0644]
web_src/js/index.js

index 39b9b97411deedf76993d547c63ad32c4c10b3b5..c0b8fd9c69746fd43617790d08062f9af376dd59 100644 (file)
@@ -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))
        }
 }
 
index dd734145d2b9f8957a9bb9eef537f50e4cf7fcad..a28e38b9907cfde57273efd874294504ded934ea 100644 (file)
@@ -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{
index 4b2bf1505d4db4e49213693dc0a4a0001ddb0484..a1c88503d89907e7e2796943831d5a7313700f8a 100644 (file)
@@ -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.
index 36446da2d160ef637e1455e0e7a540cf941c3307..b773e78a6b5ccaa4808f9753758bcf69ec2c4582 100644 (file)
@@ -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,
index 8599e072731f5b851708a86435f72702340dca78..15d17cdda7e34413da023d35b68e1891379b7866 100644 (file)
@@ -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"`
index c950591d76c9679b714d72eb91d3ebad38121941..30fa5f8a731e95346cb10a7648455bbaf8d99c91 100644 (file)
@@ -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
index e20eaf4a3be5a2f2659a829acdaa4d3f61db3551..f3ba4a81776a117f6f14c7d70cd562baf036d17c 100644 (file)
         "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",
       "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",
index 2abdc5ab7e5a5f3b88a941bd6c1eeb74a8529729..8252376643aed3a3c9317435197fe52031ca0d40 100644 (file)
@@ -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",
index 28105dfe0315229b175a196349c7c5ee1070f061..b8efb3b8413633b4a0c75f5fd0156087d90c12b4 100644 (file)
@@ -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
+}
index 34978724a8368eb3b208a904cb50680f4f928f18..f64a0a597b5840799a79d50f27e64a5c7a9795ff 100644 (file)
@@ -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()
index a2b4d4f1d9fc8e3cf198d3fcbb42c05c44335b02..efab76f33c0f4edcb417ae0acf7d2d12855bf2a2 100644 (file)
                </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>
index efebc3bf36c88b8be450b0fb9014c7ad49ff5835..f208416261c6a81adea40da4156db46e3bd3914c 100644 (file)
@@ -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">
index e46d37173b4267fef5d2a780b735db17855ca61e..0f26943da16a9e6d5f5b903673f690babd5b245d 100644 (file)
           "format": "date-time",
           "x-go-name": "Created"
         },
+        "duration": {
+          "type": "string",
+          "x-go-name": "Duration"
+        },
         "issue_index": {
           "type": "integer",
           "format": "int64",
         "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 (file)
index 0000000..d500fb5
--- /dev/null
@@ -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);
+}
index 541f32507dc28870263c4abd71f7c888e07cbd8f..9a35507bab25f43ce2e144e30673301655c860ef 100644 (file)
@@ -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(),
   ]);