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:
Norwin 2021-01-21 14:51:52 +00:00 committed by GitHub
parent 56a8929605
commit b5570d3e68
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 226 additions and 15 deletions

View file

@ -7,7 +7,6 @@ package integrations
import ( import (
"net/http" "net/http"
"testing" "testing"
"time"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
api "code.gitea.io/gitea/modules/structs" 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) issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: stopwatch.IssueID}).(*models.Issue)
if assert.Len(t, apiWatches, 1) { if assert.Len(t, apiWatches, 1) {
assert.EqualValues(t, stopwatch.CreatedUnix.AsTime().Unix(), apiWatches[0].Created.Unix()) assert.EqualValues(t, stopwatch.CreatedUnix.AsTime().Unix(), apiWatches[0].Created.Unix())
apiWatches[0].Created = time.Time{} assert.EqualValues(t, issue.Index, apiWatches[0].IssueIndex)
assert.EqualValues(t, api.StopWatch{ assert.EqualValues(t, issue.Title, apiWatches[0].IssueTitle)
Created: time.Time{}, assert.EqualValues(t, repo.Name, apiWatches[0].RepoName)
IssueIndex: issue.Index, assert.EqualValues(t, repo.OwnerName, apiWatches[0].RepoOwnerName)
IssueTitle: issue.Title, assert.Greater(t, int64(apiWatches[0].Seconds), int64(0))
RepoName: repo.Name,
RepoOwnerName: repo.OwnerName,
}, *apiWatches[0])
} }
} }

View file

@ -72,7 +72,7 @@ func TestCreateIssueAttachment(t *testing.T) {
resp := session.MakeRequest(t, req, http.StatusOK) resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body) 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") assert.True(t, exists, "The template has changed")
postData := map[string]string{ postData := map[string]string{

View file

@ -19,6 +19,16 @@ type Stopwatch struct {
CreatedUnix timeutil.TimeStamp `xorm:"created"` 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) { func getStopwatch(e Engine, userID, issueID int64) (sw *Stopwatch, exists bool, err error) {
sw = new(Stopwatch) sw = new(Stopwatch)
exists, err = e. exists, err = e.

View file

@ -147,6 +147,8 @@ func ToStopWatches(sws []*models.Stopwatch) (api.StopWatches, error) {
result = append(result, api.StopWatch{ result = append(result, api.StopWatch{
Created: sw.CreatedUnix.AsTime(), Created: sw.CreatedUnix.AsTime(),
Seconds: sw.Seconds(),
Duration: sw.Duration(),
IssueIndex: issue.Index, IssueIndex: issue.Index,
IssueTitle: issue.Title, IssueTitle: issue.Title,
RepoOwnerName: repo.OwnerName, RepoOwnerName: repo.OwnerName,

View file

@ -12,6 +12,8 @@ import (
type StopWatch struct { type StopWatch struct {
// swagger:strfmt date-time // swagger:strfmt date-time
Created time.Time `json:"created"` Created time.Time `json:"created"`
Seconds int64 `json:"seconds"`
Duration string `json:"duration"`
IssueIndex int64 `json:"issue_index"` IssueIndex int64 `json:"issue_index"`
IssueTitle string `json:"issue_title"` IssueTitle string `json:"issue_title"`
RepoOwnerName string `json:"repo_owner_name"` RepoOwnerName string `json:"repo_owner_name"`

View file

@ -15,6 +15,7 @@ page = Page
template = Template template = Template
language = Language language = Language
notifications = Notifications notifications = Notifications
active_stopwatch = Active Time Tracker
create_new = Create… create_new = Create…
user_profile_and_more = Profile and Settings… user_profile_and_more = Profile and Settings…
signed_in_as = Signed in as 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.unlock.title = Unlock conversation on this issue.
issues.comment_on_locked = You cannot comment on a locked issue. issues.comment_on_locked = You cannot comment on a locked issue.
issues.tracker = Time Tracker issues.tracker = Time Tracker
issues.start_tracking_short = Start issues.start_tracking_short = Start Timer
issues.start_tracking = Start Time Tracking issues.start_tracking = Start Time Tracking
issues.start_tracking_history = `started working %s` issues.start_tracking_history = `started working %s`
issues.tracker_auto_close = Timer will be stopped automatically when this issue gets closed 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.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.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 = Manually Add Time
issues.add_time_short = Add Time issues.add_time_short = Add Time
issues.add_time_cancel = Cancel 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_hours = Hours
issues.add_time_minutes = Minutes issues.add_time_minutes = Minutes
issues.add_time_sum_to_small = No time was entered. 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_total = Total Time Spent
issues.time_spent_from_all_authors = `Total Time Spent: %s` issues.time_spent_from_all_authors = `Total Time Spent: %s`
issues.due_date = Due Date issues.due_date = Due Date

13
package-lock.json generated
View file

@ -5293,6 +5293,11 @@
"json-parse-better-errors": "^1.0.1" "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": { "parse-node-version": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", "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==", "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
"optional": true "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": { "progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",

View file

@ -34,6 +34,7 @@
"monaco-editor": "0.21.2", "monaco-editor": "0.21.2",
"monaco-editor-webpack-plugin": "2.1.0", "monaco-editor-webpack-plugin": "2.1.0",
"postcss": "8.2.1", "postcss": "8.2.1",
"pretty-ms": "7.0.1",
"raw-loader": "4.0.2", "raw-loader": "4.0.2",
"sortablejs": "1.12.0", "sortablejs": "1.12.0",
"swagger-ui-dist": "3.38.0", "swagger-ui-dist": "3.38.0",

View file

@ -6,6 +6,7 @@ package repo
import ( import (
"net/http" "net/http"
"strings"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
@ -61,3 +62,47 @@ func CancelStopwatch(c *context.Context) {
url := issue.HTMLURL() url := issue.HTMLURL()
c.Redirect(url, http.StatusSeeOther) 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
}

View file

@ -176,6 +176,7 @@ func RegisterMacaronRoutes(m *macaron.Macaron) {
} }
m.Use(user.GetNotificationCount) m.Use(user.GetNotificationCount)
m.Use(repo.GetActiveStopwatch)
m.Use(func(ctx *context.Context) { m.Use(func(ctx *context.Context) {
ctx.Data["UnitWikiGlobalDisabled"] = models.UnitTypeWiki.UnitGlobalDisabled() ctx.Data["UnitWikiGlobalDisabled"] = models.UnitTypeWiki.UnitGlobalDisabled()
ctx.Data["UnitIssuesGlobalDisabled"] = models.UnitTypeIssues.UnitGlobalDisabled() ctx.Data["UnitIssuesGlobalDisabled"] = models.UnitTypeIssues.UnitGlobalDisabled()

View file

@ -67,6 +67,44 @@
</div> </div>
{{else if .IsSigned}} {{else if .IsSigned}}
<div class="right stackable menu"> <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"> <a href="{{AppSubUrl}}/notifications" class="item poping up" data-content='{{.i18n.Tr "notifications"}}' data-variation="tiny inverted">
<span class="text"> <span class="text">
<span class="fitted">{{svg "octicon-bell"}}</span> <span class="fitted">{{svg "octicon-bell"}}</span>

View 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}} {{.CsrfTokenHtml}}
{{if .Flash}} {{if .Flash}}
<div class="sixteen wide column"> <div class="sixteen wide column">

View file

@ -15473,6 +15473,10 @@
"format": "date-time", "format": "date-time",
"x-go-name": "Created" "x-go-name": "Created"
}, },
"duration": {
"type": "string",
"x-go-name": "Duration"
},
"issue_index": { "issue_index": {
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
@ -15489,6 +15493,11 @@
"repo_owner_name": { "repo_owner_name": {
"type": "string", "type": "string",
"x-go-name": "RepoOwnerName" "x-go-name": "RepoOwnerName"
},
"seconds": {
"type": "integer",
"format": "int64",
"x-go-name": "Seconds"
} }
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"

View 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);
}

View file

@ -22,6 +22,7 @@ import createDropzone from './features/dropzone.js';
import initTableSort from './features/tablesort.js'; import initTableSort from './features/tablesort.js';
import ActivityTopAuthors from './components/ActivityTopAuthors.vue'; import ActivityTopAuthors from './components/ActivityTopAuthors.vue';
import {initNotificationsTable, initNotificationCount} from './features/notification.js'; import {initNotificationsTable, initNotificationCount} from './features/notification.js';
import {initStopwatch} from './features/stopwatch.js';
import {createCodeEditor, createMonaco} from './features/codeeditor.js'; import {createCodeEditor, createMonaco} from './features/codeeditor.js';
import {svg, svgs} from './svg.js'; import {svg, svgs} from './svg.js';
import {stripTags} from './utils.js'; import {stripTags} from './utils.js';
@ -2626,6 +2627,7 @@ $(document).ready(async () => {
initProject(), initProject(),
initServiceWorker(), initServiceWorker(),
initNotificationCount(), initNotificationCount(),
initStopwatch(),
renderMarkdownContent(), renderMarkdownContent(),
initGithook(), initGithook(),
]); ]);