* 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 robustnesstags/v1.15.0-dev
@@ -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 | |||
@@ -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" | |||
@@ -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(), | |||
]); | |||