Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions modelmigration/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"gitea.dev/modelmigration/v1_25"
"gitea.dev/modelmigration/v1_26"
"gitea.dev/modelmigration/v1_27"
"gitea.dev/modelmigration/v1_28"
"gitea.dev/modelmigration/v1_6"
"gitea.dev/modelmigration/v1_7"
"gitea.dev/modelmigration/v1_8"
Expand Down Expand Up @@ -421,6 +422,8 @@ func prepareMigrationTasks() []*migration {
newMigration(341, "Convert legacy MSSQL DATETIME columns to DATETIME2", v1_27.FixLegacyMSSQLDateTimeColumns),
newMigration(342, "Add scoped workflows schema", v1_27.AddScopedWorkflowsSchema),
// Gitea 1.27.0 ends at migration ID number 342 (database version 343)

newMigration(343, "Add spent_on_unix to tracked_time", v1_28.AddSpentOnUnixToTrackedTime),
}
return preparedMigrations
}
Expand Down
17 changes: 17 additions & 0 deletions modelmigration/v1_28/v343.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_28

import "gitea.dev/modelmigration/base"

func AddSpentOnUnixToTrackedTime(x base.EngineMigration) error {
type TrackedTime struct {
SpentOnUnix int64 `xorm:"INDEX NOT NULL DEFAULT 0"`
}
if err := x.Sync(new(TrackedTime)); err != nil {
return err
}
_, err := x.Exec("UPDATE tracked_time SET spent_on_unix = created_unix WHERE spent_on_unix = 0")
return err
}
9 changes: 9 additions & 0 deletions models/fixtures/tracked_time.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
issue_id: 1
time: 400
created_unix: 946684800
spent_on_unix: 946684800
deleted: false

-
Expand All @@ -12,6 +13,7 @@
issue_id: 2
time: 3661
created_unix: 946684801
spent_on_unix: 946684801
deleted: false

-
Expand All @@ -20,6 +22,7 @@
issue_id: 2
time: 1
created_unix: 946684802
spent_on_unix: 946684802
deleted: false

-
Expand All @@ -28,6 +31,7 @@
issue_id: 4
time: 1
created_unix: 946684803
spent_on_unix: 946684803
deleted: false

-
Expand All @@ -36,6 +40,7 @@
issue_id: 5
time: 1
created_unix: 946684804
spent_on_unix: 946684804
deleted: false

-
Expand All @@ -44,6 +49,7 @@
issue_id: 2
time: 20
created_unix: 946684812
spent_on_unix: 946684812
deleted: false

-
Expand All @@ -52,6 +58,7 @@
issue_id: 4
time: 3
created_unix: 946684813
spent_on_unix: 946684813
deleted: false

-
Expand All @@ -60,6 +67,7 @@
issue_id: 4
time: 71
created_unix: 947688814
spent_on_unix: 947688814
deleted: false

-
Expand All @@ -68,6 +76,7 @@
issue_id: 2
time: 100000
created_unix: 947688815
spent_on_unix: 947688815
deleted: true

# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly
29 changes: 18 additions & 11 deletions models/issues/tracked_time.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type TrackedTime struct {
User *user_model.User `xorm:"-"`
Created time.Time `xorm:"-"`
CreatedUnix int64 `xorm:"created"`
SpentOn time.Time `xorm:"-"`
SpentOnUnix int64 `xorm:"INDEX NOT NULL DEFAULT 0"`
Time int64 `xorm:"NOT NULL"`
Deleted bool `xorm:"NOT NULL DEFAULT false"`
}
Expand All @@ -41,6 +43,10 @@ type TrackedTimeList []*TrackedTime
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
func (t *TrackedTime) AfterLoad() {
t.Created = time.Unix(t.CreatedUnix, 0).In(setting.DefaultUILocation)
if t.SpentOnUnix == 0 {
t.SpentOnUnix = t.CreatedUnix
}
t.SpentOn = time.Unix(t.SpentOnUnix, 0).In(setting.DefaultUILocation)
}

// LoadAttributes load Issue, User
Expand Down Expand Up @@ -109,10 +115,10 @@ func (opts *FindTrackedTimesOptions) ToConds() builder.Cond {
cond = cond.And(builder.Eq{"issue.milestone_id": opts.MilestoneID})
}
if opts.CreatedAfterUnix != 0 {
cond = cond.And(builder.Gte{"tracked_time.created_unix": opts.CreatedAfterUnix})
cond = cond.And(builder.Gte{"tracked_time.spent_on_unix": opts.CreatedAfterUnix})
}
if opts.CreatedBeforeUnix != 0 {
cond = cond.And(builder.Lte{"tracked_time.created_unix": opts.CreatedBeforeUnix})
cond = cond.And(builder.Lte{"tracked_time.spent_on_unix": opts.CreatedBeforeUnix})
Comment on lines 117 to +121
}
return cond
}
Expand Down Expand Up @@ -166,9 +172,9 @@ func GetTrackedSeconds(ctx context.Context, opts FindTrackedTimesOptions) (track
}

// AddTime will add the given time (in seconds) to the issue
func AddTime(ctx context.Context, user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
func AddTime(ctx context.Context, user *user_model.User, issue *Issue, amount int64, spentOn time.Time) (*TrackedTime, error) {
return db.WithTx2(ctx, func(ctx context.Context) (*TrackedTime, error) {
t, err := addTime(ctx, user, issue, amount, created)
t, err := addTime(ctx, user, issue, amount, spentOn)
if err != nil {
return nil, err
}
Expand All @@ -194,15 +200,16 @@ func AddTime(ctx context.Context, user *user_model.User, issue *Issue, amount in
})
}

func addTime(ctx context.Context, user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
if created.IsZero() {
created = time.Now()
func addTime(ctx context.Context, user *user_model.User, issue *Issue, amount int64, spentOn time.Time) (*TrackedTime, error) {
if spentOn.IsZero() {
spentOn = time.Now()
}
tt := &TrackedTime{
IssueID: issue.ID,
UserID: user.ID,
Time: amount,
Created: created,
IssueID: issue.ID,
UserID: user.ID,
Time: amount,
SpentOn: spentOn,
SpentOnUnix: spentOn.Unix(),
}
return tt, db.Insert(ctx, tt)
}
Expand Down
6 changes: 5 additions & 1 deletion models/issues/tracked_time_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,19 @@ func TestAddTime(t *testing.T) {
issue1, err := issues_model.GetIssueByID(t.Context(), 1)
assert.NoError(t, err)

created := time.Date(2026, time.July, 8, 0, 0, 0, 0, time.UTC)

// 3661 = 1h 1min 1s
trackedTime, err := issues_model.AddTime(t.Context(), org3, issue1, 3661, time.Now())
trackedTime, err := issues_model.AddTime(t.Context(), org3, issue1, 3661, created)
assert.NoError(t, err)
assert.Equal(t, int64(3), trackedTime.UserID)
assert.Equal(t, int64(1), trackedTime.IssueID)
assert.Equal(t, int64(3661), trackedTime.Time)
assert.Equal(t, created.Unix(), trackedTime.SpentOnUnix)

tt := unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{UserID: 3, IssueID: 1})
assert.Equal(t, int64(3661), tt.Time)
assert.Equal(t, created.Unix(), tt.SpentOnUnix)

comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{Type: issues_model.CommentTypeAddTimeManual, PosterID: 3, IssueID: 1})
assert.Equal(t, "|3661", comment.Content)
Expand Down
12 changes: 6 additions & 6 deletions models/organization/org_worktime.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ func GetWorktimeByRepos(ctx context.Context, org *Organization, unitFrom, unixTo
Join("INNER", "repository", "issue.repo_id = repository.id").
Where(builder.Eq{"repository.owner_id": org.ID}).
And(builder.Eq{"tracked_time.deleted": false}).
And(builder.Gte{"tracked_time.created_unix": unitFrom}).
And(builder.Lte{"tracked_time.created_unix": unixTo}).
And(builder.Gte{"tracked_time.spent_on_unix": unitFrom}).
And(builder.Lte{"tracked_time.spent_on_unix": unixTo}).
GroupBy("repository.name").
OrderBy("repository.name").
Find(&results)
Expand All @@ -51,8 +51,8 @@ func GetWorktimeByMilestones(ctx context.Context, org *Organization, unitFrom, u
Join("LEFT", "milestone", "issue.milestone_id = milestone.id").
Where(builder.Eq{"repository.owner_id": org.ID}).
And(builder.Eq{"tracked_time.deleted": false}).
And(builder.Gte{"tracked_time.created_unix": unitFrom}).
And(builder.Lte{"tracked_time.created_unix": unixTo}).
And(builder.Gte{"tracked_time.spent_on_unix": unitFrom}).
And(builder.Lte{"tracked_time.spent_on_unix": unixTo}).
GroupBy("repository.name, milestone.name, milestone.deadline_unix, milestone.id").
OrderBy("repository.name, milestone.deadline_unix, milestone.id").
Find(&results)
Expand Down Expand Up @@ -95,8 +95,8 @@ func GetWorktimeByMembers(ctx context.Context, org *Organization, unitFrom, unix
Join("INNER", "`user`", "tracked_time.user_id = `user`.id").
Where(builder.Eq{"repository.owner_id": org.ID}).
And(builder.Eq{"tracked_time.deleted": false}).
And(builder.Gte{"tracked_time.created_unix": unitFrom}).
And(builder.Lte{"tracked_time.created_unix": unixTo}).
And(builder.Gte{"tracked_time.spent_on_unix": unitFrom}).
And(builder.Lte{"tracked_time.spent_on_unix": unixTo}).
GroupBy("`user`.name").
OrderBy("sum_time DESC").
Find(&results)
Expand Down
3 changes: 3 additions & 0 deletions options/locale/locale_en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -1654,8 +1654,11 @@
"repo.issues.add_time_history": "added spent time <b>%[1]s</b> %[2]s",
"repo.issues.del_time_history": "deleted spent time %s",
"repo.issues.add_time_manually": "Manually Add Time",
"repo.issues.add_time_duration": "Time spent",
"repo.issues.add_time_hours": "Hours",
"repo.issues.add_time_minutes": "Minutes",
"repo.issues.add_time_spent_on": "Spent at (optional)",
"repo.issues.add_time_spent_on_invalid": "The spent at date is invalid.",
"repo.issues.add_time_sum_to_small": "No time was entered.",
"repo.issues.time_spent_total": "Total Time Spent",
"repo.issues.time_spent_from_all_authors": "Total Time Spent: %s",
Expand Down
6 changes: 3 additions & 3 deletions routers/api/v1/repo/issue_tracked_time.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,12 @@ func AddTime(ctx *context.APIContext) {
}
}

created := time.Time{}
spentOn := time.Time{}
if !form.Created.IsZero() {
created = form.Created
spentOn = form.Created
}

trackedTime, err := issues_model.AddTime(ctx, user, issue, form.Time, created)
trackedTime, err := issues_model.AddTime(ctx, user, issue, form.Time, spentOn)
Comment on lines 231 to +235
if err != nil {
ctx.APIErrorInternal(err)
return
Expand Down
14 changes: 13 additions & 1 deletion routers/web/repo/issue_timetrack.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/services/context"
Expand Down Expand Up @@ -41,7 +42,18 @@ func AddTimeManually(c *context.Context) {
return
}

if _, err := issues_model.AddTime(c, c.Doer, issue, int64(total.Seconds()), time.Now()); err != nil {
spentOnTime := time.Now()
spentOn := strings.TrimSpace(form.SpentOn)
if spentOn != "" {
parsed, err := time.ParseInLocation(time.DateOnly, spentOn, setting.DefaultUILocation)
if err != nil {
c.JSONError(c.Tr("repo.issues.add_time_spent_on_invalid"))
return
}
spentOnTime = parsed
}

if _, err := issues_model.AddTime(c, c.Doer, issue, int64(total.Seconds()), spentOnTime); err != nil {
c.ServerError("AddTime", err)
return
}
Expand Down
2 changes: 1 addition & 1 deletion services/convert/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func ToTrackedTime(ctx context.Context, doer *user_model.User, t *issues_model.T
IssueID: t.IssueID,
UserID: t.UserID,
Time: t.Time,
Created: t.Created,
Created: t.SpentOn,
}
if t.Issue != nil {
apiT.Issue = ToAPIIssue(ctx, doer, t.Issue)
Expand Down
5 changes: 3 additions & 2 deletions services/forms/repo_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -725,8 +725,9 @@ func (f *NewWikiForm) Validate(req *http.Request, errs binding.Errors) binding.E

// AddTimeManuallyForm form that adds spent time manually.
type AddTimeManuallyForm struct {
Hours int `binding:"Range(0,1000)"`
Minutes int `binding:"Range(0,1000)"`
Hours int `binding:"Range(0,1000)"`
Minutes int `binding:"Range(0,1000)"`
SpentOn string `form:"spent_on" binding:"MaxSize(10)"`
}

// Validate validates the fields
Expand Down
15 changes: 12 additions & 3 deletions templates/repo/issue/sidebar/stopwatch_timetracker.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,18 @@
<div class="ui mini modal" id="issue-time-manually-add-modal">
<div class="header">{{ctx.Locale.Tr "repo.issues.add_time_manually"}}</div>
<form method="post" class="ui form form-fetch-action" action="{{.Issue.Link}}/times/add">
<div class="content flex-text-block">
<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_hours"}}' type="number" name="hours">:
<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_minutes"}}' type="number" name="minutes">
<div class="content">
<div class="field">
<label>{{ctx.Locale.Tr "repo.issues.add_time_duration"}}</label>
<div class="flex-text-block">
<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_hours"}}' type="number" name="hours">:
<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_minutes"}}' type="number" name="minutes">
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.issues.add_time_spent_on"}}</label>
<input type="date" name="spent_on">
</div>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
Expand Down
24 changes: 24 additions & 0 deletions tests/integration/issue_timetrack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"fmt"
"net/http"
"testing"
"time"

issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
"gitea.dev/modules/setting"
"gitea.dev/tests"

"github.com/stretchr/testify/assert"
Expand All @@ -30,3 +32,25 @@ func TestIssueTimeDeleteScoped(t *testing.T) {
tracked = unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{ID: tracked.ID})
assert.False(t, tracked.Deleted)
}

func TestIssueTimeAddSpentOn(t *testing.T) {
defer tests.PrepareTestEnv(t)()

issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
assert.NoError(t, issue1.LoadRepo(t.Context()))

session := loginUser(t, issue1.Repo.OwnerName)
url := fmt.Sprintf("/%s/%s/issues/%d/times/add", issue1.Repo.OwnerName, issue1.Repo.Name, issue1.Index)
req := NewRequestWithValues(t, "POST", url, map[string]string{
"hours": "2",
"minutes": "30",
"spent_on": "2026-07-08",
})
session.MakeRequest(t, req, http.StatusOK)

created, err := time.ParseInLocation(time.DateOnly, "2026-07-08", setting.DefaultUILocation)
assert.NoError(t, err)
tracked := unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{UserID: 2, IssueID: issue1.ID, Time: 9000})
assert.Equal(t, created.Unix(), tracked.SpentOnUnix)
assert.NotEqual(t, tracked.CreatedUnix, tracked.SpentOnUnix)
}
Loading