diff --git a/models/issues/stopwatch.go b/models/issues/stopwatch.go index 2c662bdb06a80..1d56cb6e150c9 100644 --- a/models/issues/stopwatch.go +++ b/models/issues/stopwatch.go @@ -60,11 +60,6 @@ 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 util.SecToTime(s.Seconds()) -} - func getStopwatch(ctx context.Context, userID, issueID int64) (sw *Stopwatch, exists bool, err error) { sw = new(Stopwatch) exists, err = db.GetEngine(ctx). @@ -215,7 +210,7 @@ func FinishIssueStopwatch(ctx context.Context, user *user_model.User, issue *Iss Doer: user, Issue: issue, Repo: issue.Repo, - Content: util.SecToTime(timediff), + Content: fmt.Sprintf("|%d", timediff), Type: CommentTypeStopTracking, TimeID: tt.ID, }); err != nil { diff --git a/models/user/setting_keys.go b/models/user/setting_keys.go index 72b3974eee435..0c4e1ee49f47b 100644 --- a/models/user/setting_keys.go +++ b/models/user/setting_keys.go @@ -14,4 +14,6 @@ const ( UserActivityPubPrivPem = "activitypub.priv_pem" // UserActivityPubPubPem is user's public key UserActivityPubPubPem = "activitypub.pub_pem" + // SettingsKeyTrackedTimeMaxUnit set how tracked time values are converted from seconds to sting + SettingsKeyTrackedTimeMaxUnit = "tracked_time.max_unit" ) diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 235fd96b73d8f..d4fb4ef5db5d0 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -62,12 +62,13 @@ func NewFuncMap() template.FuncMap { // ----------------------------------------------------------------- // time / number / format - "FileSize": base.FileSize, - "CountFmt": base.FormatNumberSI, - "TimeSince": timeutil.TimeSince, - "TimeSinceUnix": timeutil.TimeSinceUnix, - "DateTime": timeutil.DateTime, - "Sec2Time": util.SecToTime, + "FileSize": base.FileSize, + "CountFmt": base.FormatNumberSI, + "TimeSince": timeutil.TimeSince, + "TimeSinceUnix": timeutil.TimeSinceUnix, + "DateTime": timeutil.DateTime, + "Sec2Time": util.SecToTime, + "Sec2TrackedTime": util.Sec2TrackedTime, "LoadTimes": func(startTime time.Time) string { return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms" }, diff --git a/modules/util/sec_to_time.go b/modules/util/sec_to_time.go index ad0fb1a68b4a7..67d6e0a6c682d 100644 --- a/modules/util/sec_to_time.go +++ b/modules/util/sec_to_time.go @@ -9,11 +9,11 @@ import ( ) // SecToTime converts an amount of seconds to a human-readable string. E.g. -// 66s -> 1 minute 6 seconds -// 52410s -> 14 hours 33 minutes -// 563418 -> 6 days 12 hours +// 66s -> 1 minute 6 seconds +// 52410s -> 14 hours 33 minutes +// 563418 -> 6 days 12 hours // 1563418 -> 2 weeks 4 days -// 3937125s -> 1 month 2 weeks +// 3937125s -> 1 month 2 weeks // 45677465s -> 1 year 6 months func SecToTime(durationVal any) string { duration, _ := ToInt64(durationVal) @@ -79,3 +79,36 @@ func formatTime(value int64, name, formattedTime string) string { return formattedTime } + +// Sec2TrackedTime converts an amount of seconds to a human-readable string. E.g. +// 66s -> 1 minute 6 seconds +// 52410s -> 14 hours 33 minutes +// 563418s -> 156 hours 30 minutes +// 1563418s -> 434 hours 16 minutes +// 3937125s -> 1093 hours 38 minutes +// 45677465s -> 12688 hours 11 minutes +func Sec2TrackedTime(durationVal any) string { + duration, _ := ToInt64(durationVal) + + formattedTime := "" + + // The following three variables are calculated without depending + // on the previous calculated variables. + hours := (duration / 3600) + minutes := (duration / 60) % 60 + seconds := duration % 60 + + // Extract only the relevant information of the time + // If the time is greater than a year, it makes no sense to display seconds. + switch { + case hours > 0: + formattedTime = formatTime(hours, "hour", formattedTime) + formattedTime = formatTime(minutes, "minute", formattedTime) + default: + formattedTime = formatTime(minutes, "minute", formattedTime) + formattedTime = formatTime(seconds, "second", formattedTime) + } + + // The formatTime() function always appends a space at the end. This will be trimmed + return strings.TrimRight(formattedTime, " ") +} diff --git a/modules/util/sec_to_time_test.go b/modules/util/sec_to_time_test.go index 4d1213a52c05e..c9d553819c215 100644 --- a/modules/util/sec_to_time_test.go +++ b/modules/util/sec_to_time_test.go @@ -28,3 +28,23 @@ func TestSecToTime(t *testing.T) { assert.Equal(t, "11 months", SecToTime(year-25*day)) assert.Equal(t, "1 year 5 months", SecToTime(year+163*day+10*hour+11*minute+5*second)) } + +func TestSec2TrackedTime(t *testing.T) { + second := int64(1) + minute := 60 * second + hour := 60 * minute + day := 24 * hour + year := 365 * day + + assert.Equal(t, "1 minute 6 seconds", Sec2TrackedTime(minute+6*second)) + assert.Equal(t, "1 hour", Sec2TrackedTime(hour)) + assert.Equal(t, "1 hour", Sec2TrackedTime(hour+second)) + assert.Equal(t, "14 hours 33 minutes", Sec2TrackedTime(14*hour+33*minute+30*second)) + assert.Equal(t, "156 hours 30 minutes", Sec2TrackedTime(6*day+12*hour+30*minute+18*second)) + assert.Equal(t, "434 hours 16 minutes", Sec2TrackedTime((2*7+4)*day+2*hour+16*minute+58*second)) + assert.Equal(t, "672 hours", Sec2TrackedTime(4*7*day)) + assert.Equal(t, "696 hours", Sec2TrackedTime((4*7+1)*day)) + assert.Equal(t, "1093 hours 38 minutes", Sec2TrackedTime((6*7+3)*day+13*hour+38*minute+45*second)) + assert.Equal(t, "8160 hours", Sec2TrackedTime(year-25*day)) + assert.Equal(t, "12682 hours 11 minutes", Sec2TrackedTime(year+163*day+10*hour+11*minute+5*second)) +} diff --git a/package-lock.json b/package-lock.json index 5a49efee23e8c..2aab59bec1564 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "monaco-editor": "0.44.0", "monaco-editor-webpack-plugin": "7.1.0", "pdfobject": "2.2.12", - "pretty-ms": "8.0.0", "sortablejs": "1.15.0", "swagger-ui-dist": "5.10.0", "throttle-debounce": "5.0.0", @@ -8529,17 +8528,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-ms": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-3.0.0.tgz", - "integrity": "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -9015,20 +9003,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-ms": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-8.0.0.tgz", - "integrity": "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==", - "dependencies": { - "parse-ms": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/printable-characters": { "version": "1.0.42", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", diff --git a/package.json b/package.json index 4aee1bb04943e..d74122a56c484 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "monaco-editor": "0.44.0", "monaco-editor-webpack-plugin": "7.1.0", "pdfobject": "2.2.12", - "pretty-ms": "8.0.0", "sortablejs": "1.15.0", "swagger-ui-dist": "5.10.0", "throttle-debounce": "5.0.0", diff --git a/routers/web/repo/issue_timetrack.go b/routers/web/repo/issue_timetrack.go index c9bf861b844b8..2a4283a2681f7 100644 --- a/routers/web/repo/issue_timetrack.go +++ b/routers/web/repo/issue_timetrack.go @@ -82,6 +82,6 @@ func DeleteTime(c *context.Context) { return } - c.Flash.Success(c.Tr("repo.issues.del_time_history", util.SecToTime(t.Time))) + c.Flash.Success(c.Tr("repo.issues.del_time_history", util.Sec2TrackedTime(t.Time))) c.Redirect(issue.Link()) } diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index d8331fef43f5a..3a04c78999e45 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -350,6 +350,12 @@ func Appearance(ctx *context.Context) { return forms.IsUserHiddenCommentTypeGroupChecked(commentTypeGroup, hiddenCommentTypes) } + ctx.Data["TrackedTimeMaxUnit"], err = user_model.GetUserSetting(ctx.Doer.ID, user_model.SettingsKeyTrackedTimeMaxUnit, "year") // TODO: make default set via system? + if err != nil { + ctx.ServerError("GetUserSetting", err) + return + } + ctx.HTML(http.StatusOK, tplSettingsAppearance) } diff --git a/services/convert/issue.go b/services/convert/issue.go index 39d785e108332..71b5e3bc5390d 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" ) func ToIssue(ctx context.Context, issue *issues_model.Issue) *api.Issue { @@ -180,7 +181,7 @@ func ToStopWatches(ctx context.Context, sws []*issues_model.Stopwatch) (api.Stop result = append(result, api.StopWatch{ Created: sw.CreatedUnix.AsTime(), Seconds: sw.Seconds(), - Duration: sw.Duration(), + Duration: util.SecToTime(sw.Seconds()), IssueIndex: issue.Index, IssueTitle: issue.Title, RepoOwnerName: repo.OwnerName, diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index effe4dcea9f8f..322a874822bbb 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -88,7 +88,7 @@ {{svg "octicon-issue-opened" 16 "gt-mr-3"}} {{.ActiveStopwatch.RepoSlug}}#{{.ActiveStopwatch.IssueIndex}} - {{if .ActiveStopwatch}}{{Sec2Time .ActiveStopwatch.Seconds}}{{end}} + {{if .ActiveStopwatch}}{{Sec2TrackedTime .ActiveStopwatch.Seconds}}{{end}}
diff --git a/templates/repo/issue/filters.tmpl b/templates/repo/issue/filters.tmpl index 1d200e23b7a7f..2c864ae732606 100644 --- a/templates/repo/issue/filters.tmpl +++ b/templates/repo/issue/filters.tmpl @@ -9,7 +9,7 @@ {{end}} diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl index 012b613fbf879..6a8b0daa6f223 100644 --- a/templates/repo/issue/list.tmpl +++ b/templates/repo/issue/list.tmpl @@ -39,7 +39,7 @@ {{end}} diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl index ea19518efac83..bd99d1f2f5a7d 100644 --- a/templates/repo/issue/milestone_issues.tmpl +++ b/templates/repo/issue/milestone_issues.tmpl @@ -49,7 +49,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock"}} - {{.TotalTrackedTime | Sec2Time}} + {{.TotalTrackedTime | Sec2TrackedTime}}
{{end}} diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl index 3d4bbfd8b1ada..072b5657470b6 100644 --- a/templates/repo/issue/milestones.tmpl +++ b/templates/repo/issue/milestones.tmpl @@ -41,7 +41,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock"}} - {{.TotalTrackedTime|Sec2Time}} + {{.TotalTrackedTime|Sec2TrackedTime}}
{{end}} {{if .UpdatedUnix}} diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 817f20af203ec..bf8eebb90e77a 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -252,7 +252,7 @@ {{/* compatibility with time comments made before v1.21 */}} {{.RenderedContent}} {{else}} - {{.Content|Sec2Time}} + {{.Content|Sec2TrackedTime}} {{end}} @@ -271,7 +271,7 @@ {{/* compatibility with time comments made before v1.21 */}} {{.RenderedContent}} {{else}} - {{.Content|Sec2Time}} + {{.Content|Sec2TrackedTime}} {{end}} @@ -647,7 +647,7 @@ {{/* compatibility with time comments made before v1.21 */}} {{.RenderedContent}} {{else}} - - {{.Content|Sec2Time}} + - {{.Content|Sec2TrackedTime}} {{end}} diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 4be1f52dd5c69..ebddeeb47d7ef 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -342,7 +342,7 @@ {{if gt (len .WorkingUsers) 0}}
- {{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Time) | Safe}} + {{ctx.locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2TrackedTime) | Safe}}
{{range $user, $trackedtime := .WorkingUsers}}
@@ -352,7 +352,7 @@
{{template "shared/user/authorlink" $user}}
- {{$trackedtime|Sec2Time}} + {{$trackedtime|Sec2TrackedTime}}
diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index e0d2e102e5ef7..287978c1b785d 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -30,7 +30,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock" 16}} - {{.TotalTrackedTime | Sec2Time}} + {{.TotalTrackedTime | Sec2TrackedTime}}
{{end}} {{if .Assignees}} diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl index 390457a60a3fe..6c69edc008c82 100644 --- a/templates/user/dashboard/milestones.tmpl +++ b/templates/user/dashboard/milestones.tmpl @@ -100,7 +100,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock"}} - {{.TotalTrackedTime|Sec2Time}} + {{.TotalTrackedTime|Sec2TrackedTime}}
{{end}} {{if .UpdatedUnix}} diff --git a/web_src/js/features/stopwatch.js b/web_src/js/features/stopwatch.js index f43014fec5b7d..39926cb16b6e2 100644 --- a/web_src/js/features/stopwatch.js +++ b/web_src/js/features/stopwatch.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import prettyMilliseconds from 'pretty-ms'; +import {formatTrackedTime} from '../utils/time.js'; import {createTippy} from '../modules/tippy.js'; const {appSubUrl, csrfToken, notificationSettings, enableTimeTracking, assetVersionEncoded} = window.config; @@ -154,8 +154,8 @@ function updateStopwatchTime(seconds) { const $stopwatch = $('.stopwatch-time'); const start = Date.now(); const updateUi = () => { - const delta = Date.now() - start; - const dur = prettyMilliseconds(secs * 1000 + delta, {compact: true}); + const delta = (Date.now() - start) / 1000; + const dur = formatTrackedTime(secs + delta); $stopwatch.text(dur); }; updateUi(); diff --git a/web_src/js/utils/time.js b/web_src/js/utils/time.js new file mode 100644 index 0000000000000..1edf8040eeefc --- /dev/null +++ b/web_src/js/utils/time.js @@ -0,0 +1,28 @@ +export function formatTrackedTime(durationSec) { + let formattedTime = ''; + + const duration = Math.floor(durationSec / 1); + const hours = Math.floor(duration / 3600); + const minutes = Math.floor((duration / 60) % 60); + const seconds = duration % 60; + + if (hours > 0) { + formattedTime = formatTime(hours, 'hour', formattedTime); + formattedTime = formatTime(minutes, 'minute', formattedTime); + } else { + formattedTime = formatTime(minutes, 'minute', formattedTime); + formattedTime = formatTime(seconds, 'second', formattedTime); + } + + formattedTime = formattedTime.trimEnd(); + return formattedTime; +} + +function formatTime(value, name, formattedTime) { + if (value === 1) { + formattedTime = `${formattedTime}1 ${name} `; + } else if (value > 1) { + formattedTime = `${formattedTime}${value} ${name}s `; + } + return formattedTime; +} diff --git a/web_src/js/utils/time.test.js b/web_src/js/utils/time.test.js new file mode 100644 index 0000000000000..d8b220622e0f1 --- /dev/null +++ b/web_src/js/utils/time.test.js @@ -0,0 +1,15 @@ +import {test, expect} from 'vitest'; +import {formatTrackedTime} from './time.js'; + +test('formatTrackedTime', () => { + expect(formatTrackedTime('')).toEqual(''); + expect(formatTrackedTime('0')).toEqual(''); + expect(formatTrackedTime('66')).toEqual('1 minute 6 seconds'); + expect(formatTrackedTime('52410')).toEqual('14 hours 33 minutes'); + expect(formatTrackedTime('563418')).toEqual('156 hours 30 minutes'); + expect(formatTrackedTime('1563418')).toEqual('434 hours 16 minutes'); + expect(formatTrackedTime('3937125')).toEqual('1093 hours 38 minutes'); + expect(formatTrackedTime('45677465')).toEqual('12688 hours 11 minutes'); + expect(formatTrackedTime(1.333)).toEqual('1 second'); + expect(formatTrackedTime(1.999)).toEqual('1 second'); +});