Skip to content

Commit 936665b

Browse files
stuzer05stuzer05yardenshohamsilverwind6543
authored
Issue time estimate, meaningful time tracking (#23113)
Redesign the time tracker side bar, and add "time estimate" support (in "1d 2m" format) Closes #23112 --------- Co-authored-by: stuzer05 <[email protected]> Co-authored-by: Yarden Shoham <[email protected]> Co-authored-by: silverwind <[email protected]> Co-authored-by: 6543 <[email protected]> Co-authored-by: wxiaoguang <[email protected]>
1 parent c5422fa commit 936665b

File tree

21 files changed

+390
-164
lines changed

21 files changed

+390
-164
lines changed

Diff for: models/issues/comment.go

+3
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ const (
114114

115115
CommentTypePin // 36 pin Issue
116116
CommentTypeUnpin // 37 unpin Issue
117+
118+
CommentTypeChangeTimeEstimate // 38 Change time estimate
117119
)
118120

119121
var commentStrings = []string{
@@ -155,6 +157,7 @@ var commentStrings = []string{
155157
"pull_cancel_scheduled_merge",
156158
"pin",
157159
"unpin",
160+
"change_time_estimate",
158161
}
159162

160163
func (t CommentType) String() string {

Diff for: models/issues/issue.go

+28
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ type Issue struct {
147147

148148
// For view issue page.
149149
ShowRole RoleDescriptor `xorm:"-"`
150+
151+
// Time estimate
152+
TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"`
150153
}
151154

152155
var (
@@ -934,3 +937,28 @@ func insertIssue(ctx context.Context, issue *Issue) error {
934937

935938
return nil
936939
}
940+
941+
// ChangeIssueTimeEstimate changes the plan time of this issue, as the given user.
942+
func ChangeIssueTimeEstimate(ctx context.Context, issue *Issue, doer *user_model.User, timeEstimate int64) error {
943+
return db.WithTx(ctx, func(ctx context.Context) error {
944+
if err := UpdateIssueCols(ctx, &Issue{ID: issue.ID, TimeEstimate: timeEstimate}, "time_estimate"); err != nil {
945+
return fmt.Errorf("updateIssueCols: %w", err)
946+
}
947+
948+
if err := issue.LoadRepo(ctx); err != nil {
949+
return fmt.Errorf("loadRepo: %w", err)
950+
}
951+
952+
opts := &CreateCommentOptions{
953+
Type: CommentTypeChangeTimeEstimate,
954+
Doer: doer,
955+
Repo: issue.Repo,
956+
Issue: issue,
957+
Content: fmt.Sprintf("%d", timeEstimate),
958+
}
959+
if _, err := CreateComment(ctx, opts); err != nil {
960+
return fmt.Errorf("createComment: %w", err)
961+
}
962+
return nil
963+
})
964+
}

Diff for: models/migrations/migrations.go

+1
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ func prepareMigrationTasks() []*migration {
368368
newMigration(308, "Add index(user_id, is_deleted) for action table", v1_23.AddNewIndexForUserDashboard),
369369
newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices),
370370
newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch),
371+
newMigration(311, "Add TimeEstimate to Issue table", v1_23.AddTimeEstimateColumnToIssueTable),
371372
}
372373
return preparedMigrations
373374
}

Diff for: models/migrations/v1_23/v311.go

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package v1_23 //nolint
5+
6+
import (
7+
"xorm.io/xorm"
8+
)
9+
10+
func AddTimeEstimateColumnToIssueTable(x *xorm.Engine) error {
11+
type Issue struct {
12+
TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"`
13+
}
14+
15+
return x.Sync(new(Issue))
16+
}

Diff for: modules/templates/helper.go

+11
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ func NewFuncMap() template.FuncMap {
7070
"FileSize": base.FileSize,
7171
"CountFmt": base.FormatNumberSI,
7272
"Sec2Time": util.SecToTime,
73+
74+
"TimeEstimateString": timeEstimateString,
75+
7376
"LoadTimes": func(startTime time.Time) string {
7477
return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
7578
},
@@ -282,6 +285,14 @@ func userThemeName(user *user_model.User) string {
282285
return setting.UI.DefaultTheme
283286
}
284287

288+
func timeEstimateString(timeSec any) string {
289+
v, _ := util.ToInt64(timeSec)
290+
if v == 0 {
291+
return ""
292+
}
293+
return util.TimeEstimateString(v)
294+
}
295+
285296
func panicIfDevOrTesting() {
286297
if !setting.IsProd || setting.IsInTesting {
287298
panic("legacy template functions are for backward compatibility only, do not use them in new code")

Diff for: modules/util/time_str.go

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2024 Gitea. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package util
5+
6+
import (
7+
"fmt"
8+
"regexp"
9+
"strconv"
10+
"strings"
11+
"sync"
12+
)
13+
14+
type timeStrGlobalVarsType struct {
15+
units []struct {
16+
name string
17+
num int64
18+
}
19+
re *regexp.Regexp
20+
}
21+
22+
// When tracking working time, only hour/minute/second units are accurate and could be used.
23+
// For other units like "day", it depends on "how many working hours in a day": 6 or 7 or 8?
24+
// So at the moment, we only support hour/minute/second units.
25+
// In the future, it could be some configurable options to help users
26+
// to convert the working time to different units.
27+
28+
var timeStrGlobalVars = sync.OnceValue[*timeStrGlobalVarsType](func() *timeStrGlobalVarsType {
29+
v := &timeStrGlobalVarsType{}
30+
v.re = regexp.MustCompile(`(?i)(\d+)\s*([hms])`)
31+
v.units = []struct {
32+
name string
33+
num int64
34+
}{
35+
{"h", 60 * 60},
36+
{"m", 60},
37+
{"s", 1},
38+
}
39+
return v
40+
})
41+
42+
func TimeEstimateParse(timeStr string) (int64, error) {
43+
if timeStr == "" {
44+
return 0, nil
45+
}
46+
var total int64
47+
matches := timeStrGlobalVars().re.FindAllStringSubmatchIndex(timeStr, -1)
48+
if len(matches) == 0 {
49+
return 0, fmt.Errorf("invalid time string: %s", timeStr)
50+
}
51+
if matches[0][0] != 0 || matches[len(matches)-1][1] != len(timeStr) {
52+
return 0, fmt.Errorf("invalid time string: %s", timeStr)
53+
}
54+
for _, match := range matches {
55+
amount, err := strconv.ParseInt(timeStr[match[2]:match[3]], 10, 64)
56+
if err != nil {
57+
return 0, fmt.Errorf("invalid time string: %v", err)
58+
}
59+
unit := timeStr[match[4]:match[5]]
60+
found := false
61+
for _, u := range timeStrGlobalVars().units {
62+
if strings.ToLower(unit) == u.name {
63+
total += amount * u.num
64+
found = true
65+
break
66+
}
67+
}
68+
if !found {
69+
return 0, fmt.Errorf("invalid time unit: %s", unit)
70+
}
71+
}
72+
return total, nil
73+
}
74+
75+
func TimeEstimateString(amount int64) string {
76+
var timeParts []string
77+
for _, u := range timeStrGlobalVars().units {
78+
if amount >= u.num {
79+
num := amount / u.num
80+
amount %= u.num
81+
timeParts = append(timeParts, fmt.Sprintf("%d%s", num, u.name))
82+
}
83+
}
84+
return strings.Join(timeParts, " ")
85+
}

Diff for: modules/util/time_str_test.go

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2024 Gitea. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package util
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestTimeStr(t *testing.T) {
13+
t.Run("Parse", func(t *testing.T) {
14+
// Test TimeEstimateParse
15+
tests := []struct {
16+
input string
17+
output int64
18+
err bool
19+
}{
20+
{"1h", 3600, false},
21+
{"1m", 60, false},
22+
{"1s", 1, false},
23+
{"1h 1m 1s", 3600 + 60 + 1, false},
24+
{"1d1x", 0, true},
25+
}
26+
for _, test := range tests {
27+
t.Run(test.input, func(t *testing.T) {
28+
output, err := TimeEstimateParse(test.input)
29+
if test.err {
30+
assert.NotNil(t, err)
31+
} else {
32+
assert.Nil(t, err)
33+
}
34+
assert.Equal(t, test.output, output)
35+
})
36+
}
37+
})
38+
t.Run("String", func(t *testing.T) {
39+
tests := []struct {
40+
input int64
41+
output string
42+
}{
43+
{3600, "1h"},
44+
{60, "1m"},
45+
{1, "1s"},
46+
{3600 + 1, "1h 1s"},
47+
}
48+
for _, test := range tests {
49+
t.Run(test.output, func(t *testing.T) {
50+
output := TimeEstimateString(test.input)
51+
assert.Equal(t, test.output, output)
52+
})
53+
}
54+
})
55+
}

Diff for: options/locale/locale_en-US.ini

+17-10
Original file line numberDiff line numberDiff line change
@@ -1670,27 +1670,34 @@ issues.comment_on_locked = You cannot comment on a locked issue.
16701670
issues.delete = Delete
16711671
issues.delete.title = Delete this issue?
16721672
issues.delete.text = Do you really want to delete this issue? (This will permanently remove all content. Consider closing it instead, if you intend to keep it archived)
1673+
16731674
issues.tracker = Time Tracker
1674-
issues.start_tracking_short = Start Timer
1675-
issues.start_tracking = Start Time Tracking
1676-
issues.start_tracking_history = `started working %s`
1675+
issues.timetracker_timer_start = Start timer
1676+
issues.timetracker_timer_stop = Stop timer
1677+
issues.timetracker_timer_discard = Discard timer
1678+
issues.timetracker_timer_manually_add = Add Time
1679+
1680+
issues.time_estimate_placeholder = 1h 2m
1681+
issues.time_estimate_set = Set estimated time
1682+
issues.time_estimate_display = Estimate: %s
1683+
issues.change_time_estimate_at = changed time estimate to <b>%s</b> %s
1684+
issues.remove_time_estimate_at = removed time estimate %s
1685+
issues.time_estimate_invalid = Time estimate format is invalid
1686+
issues.start_tracking_history = started working %s
16771687
issues.tracker_auto_close = Timer will be stopped automatically when this issue gets closed
16781688
issues.tracking_already_started = `You have already started time tracking on <a href="%s">another issue</a>!`
1679-
issues.stop_tracking = Stop Timer
1680-
issues.stop_tracking_history = `stopped working %s`
1681-
issues.cancel_tracking = Discard
1689+
issues.stop_tracking_history = worked for <b>%s</b> %s
16821690
issues.cancel_tracking_history = `canceled time tracking %s`
1683-
issues.add_time = Manually Add Time
16841691
issues.del_time = Delete this time log
1685-
issues.add_time_short = Add Time
1686-
issues.add_time_cancel = Cancel
1687-
issues.add_time_history = `added spent time %s`
1692+
issues.add_time_history = added spent time <b>%s</b> %s
16881693
issues.del_time_history= `deleted spent time %s`
1694+
issues.add_time_manually = Manually Add Time
16891695
issues.add_time_hours = Hours
16901696
issues.add_time_minutes = Minutes
16911697
issues.add_time_sum_to_small = No time was entered.
16921698
issues.time_spent_total = Total Time Spent
16931699
issues.time_spent_from_all_authors = `Total Time Spent: %s`
1700+
16941701
issues.due_date = Due Date
16951702
issues.invalid_due_date_format = "Due date format must be 'yyyy-mm-dd'."
16961703
issues.error_modifying_due_date = "Failed to modify the due date."

Diff for: routers/web/repo/issue_stopwatch.go

+2-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package repo
55

66
import (
7-
"net/http"
87
"strings"
98

109
"code.gitea.io/gitea/models/db"
@@ -40,8 +39,7 @@ func IssueStopwatch(c *context.Context) {
4039
c.Flash.Success(c.Tr("repo.issues.tracker_auto_close"))
4140
}
4241

43-
url := issue.Link()
44-
c.Redirect(url, http.StatusSeeOther)
42+
c.JSONRedirect("")
4543
}
4644

4745
// CancelStopwatch cancel the stopwatch
@@ -72,8 +70,7 @@ func CancelStopwatch(c *context.Context) {
7270
})
7371
}
7472

75-
url := issue.Link()
76-
c.Redirect(url, http.StatusSeeOther)
73+
c.JSONRedirect("")
7774
}
7875

7976
// GetActiveStopwatch is the middleware that sets .ActiveStopwatch on context

0 commit comments

Comments
 (0)