Skip to content

Commit 34692a2

Browse files
kkovacsuserwxiaoguang
authored
Worktime tracking for the organization level (#19808)
Dear Gitea team, first of all, thanks for the great work you're doing with this project. I'm planning to introduce Gitea at a client site, and noticed that while there is time recording, there are no project-manager-friendly reports to actually make use of that data, as were also mentioned by others in #4870 #8684 and #13531. Since I had a little time last weekend, I had put together something that I hope to be a useful contribution to this great project (while of course useful for me too). This PR adds a new "Worktime" tab to the Organisation level. There is a date range selector (by default set to the current month), and there are three possible views: - by repository, - by milestone, and - by team member. Happy to receive any feedback! There are several possible future improvements of course (predefined date ranges, charts, a member time sheet, matrix of repos/members, etc) but I hope that even in this relatively simple state this would be useful to lots of people. <img width="1161" alt="Screen Shot 2022-05-25 at 22 12 58" src="https://user-images.githubusercontent.com/118010/170366976-af00c7af-c4f3-4117-86d7-00356d6797a5.png"> Keep up the good work! Kristof --------- Co-authored-by: user <user@kk-git1> Co-authored-by: wxiaoguang <[email protected]>
1 parent 869f8fd commit 34692a2

File tree

22 files changed

+612
-15
lines changed

22 files changed

+612
-15
lines changed

models/organization/org_worktime.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package organization
5+
6+
import (
7+
"sort"
8+
9+
"code.gitea.io/gitea/models/db"
10+
11+
"xorm.io/builder"
12+
)
13+
14+
type WorktimeSumByRepos struct {
15+
RepoName string
16+
SumTime int64
17+
}
18+
19+
func GetWorktimeByRepos(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByRepos, err error) {
20+
err = db.GetEngine(db.DefaultContext).
21+
Select("repository.name AS repo_name, SUM(tracked_time.time) AS sum_time").
22+
Table("tracked_time").
23+
Join("INNER", "issue", "tracked_time.issue_id = issue.id").
24+
Join("INNER", "repository", "issue.repo_id = repository.id").
25+
Where(builder.Eq{"repository.owner_id": org.ID}).
26+
And(builder.Eq{"tracked_time.deleted": false}).
27+
And(builder.Gte{"tracked_time.created_unix": unitFrom}).
28+
And(builder.Lte{"tracked_time.created_unix": unixTo}).
29+
GroupBy("repository.name").
30+
OrderBy("repository.name").
31+
Find(&results)
32+
return results, err
33+
}
34+
35+
type WorktimeSumByMilestones struct {
36+
RepoName string
37+
MilestoneName string
38+
MilestoneID int64
39+
MilestoneDeadline int64
40+
SumTime int64
41+
HideRepoName bool
42+
}
43+
44+
func GetWorktimeByMilestones(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMilestones, err error) {
45+
err = db.GetEngine(db.DefaultContext).
46+
Select("repository.name AS repo_name, milestone.name AS milestone_name, milestone.id AS milestone_id, milestone.deadline_unix as milestone_deadline, SUM(tracked_time.time) AS sum_time").
47+
Table("tracked_time").
48+
Join("INNER", "issue", "tracked_time.issue_id = issue.id").
49+
Join("INNER", "repository", "issue.repo_id = repository.id").
50+
Join("LEFT", "milestone", "issue.milestone_id = milestone.id").
51+
Where(builder.Eq{"repository.owner_id": org.ID}).
52+
And(builder.Eq{"tracked_time.deleted": false}).
53+
And(builder.Gte{"tracked_time.created_unix": unitFrom}).
54+
And(builder.Lte{"tracked_time.created_unix": unixTo}).
55+
GroupBy("repository.name, milestone.name, milestone.deadline_unix, milestone.id").
56+
OrderBy("repository.name, milestone.deadline_unix, milestone.id").
57+
Find(&results)
58+
59+
// TODO: pgsql: NULL values are sorted last in default ascending order, so we need to sort them manually again.
60+
sort.Slice(results, func(i, j int) bool {
61+
if results[i].RepoName != results[j].RepoName {
62+
return results[i].RepoName < results[j].RepoName
63+
}
64+
if results[i].MilestoneDeadline != results[j].MilestoneDeadline {
65+
return results[i].MilestoneDeadline < results[j].MilestoneDeadline
66+
}
67+
return results[i].MilestoneID < results[j].MilestoneID
68+
})
69+
70+
// Show only the first RepoName, for nicer output.
71+
prevRepoName := ""
72+
for i := 0; i < len(results); i++ {
73+
res := &results[i]
74+
res.MilestoneDeadline = 0 // clear the deadline because we do not really need it
75+
if prevRepoName == res.RepoName {
76+
res.HideRepoName = true
77+
}
78+
prevRepoName = res.RepoName
79+
}
80+
return results, err
81+
}
82+
83+
type WorktimeSumByMembers struct {
84+
UserName string
85+
SumTime int64
86+
}
87+
88+
func GetWorktimeByMembers(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMembers, err error) {
89+
err = db.GetEngine(db.DefaultContext).
90+
Select("`user`.name AS user_name, SUM(tracked_time.time) AS sum_time").
91+
Table("tracked_time").
92+
Join("INNER", "issue", "tracked_time.issue_id = issue.id").
93+
Join("INNER", "repository", "issue.repo_id = repository.id").
94+
Join("INNER", "`user`", "tracked_time.user_id = `user`.id").
95+
Where(builder.Eq{"repository.owner_id": org.ID}).
96+
And(builder.Eq{"tracked_time.deleted": false}).
97+
And(builder.Gte{"tracked_time.created_unix": unitFrom}).
98+
And(builder.Lte{"tracked_time.created_unix": unixTo}).
99+
GroupBy("`user`.name").
100+
OrderBy("sum_time DESC").
101+
Find(&results)
102+
return results, err
103+
}

modules/templates/helper.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func NewFuncMap() template.FuncMap {
6969
// time / number / format
7070
"FileSize": base.FileSize,
7171
"CountFmt": countFmt,
72-
"Sec2Time": util.SecToHours,
72+
"Sec2Hour": util.SecToHours,
7373

7474
"TimeEstimateString": timeEstimateString,
7575

modules/util/sec_to_time.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,20 @@ import (
1111
// SecToHours converts an amount of seconds to a human-readable hours string.
1212
// This is stable for planning and managing timesheets.
1313
// Here it only supports hours and minutes, because a work day could contain 6 or 7 or 8 hours.
14+
// If the duration is less than 1 minute, it will be shown as seconds.
1415
func SecToHours(durationVal any) string {
15-
duration, _ := ToInt64(durationVal)
16-
hours := duration / 3600
17-
minutes := (duration / 60) % 60
16+
seconds, _ := ToInt64(durationVal)
17+
hours := seconds / 3600
18+
minutes := (seconds / 60) % 60
1819

1920
formattedTime := ""
2021
formattedTime = formatTime(hours, "hour", formattedTime)
2122
formattedTime = formatTime(minutes, "minute", formattedTime)
2223

2324
// The formatTime() function always appends a space at the end. This will be trimmed
25+
if formattedTime == "" && seconds > 0 {
26+
formattedTime = formatTime(seconds, "second", "")
27+
}
2428
return strings.TrimRight(formattedTime, " ")
2529
}
2630

modules/util/sec_to_time_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,7 @@ func TestSecToHours(t *testing.T) {
2222
assert.Equal(t, "156 hours 30 minutes", SecToHours(6*day+12*hour+30*minute+18*second))
2323
assert.Equal(t, "98 hours 16 minutes", SecToHours(4*day+2*hour+16*minute+58*second))
2424
assert.Equal(t, "672 hours", SecToHours(4*7*day))
25+
assert.Equal(t, "1 second", SecToHours(1))
26+
assert.Equal(t, "2 seconds", SecToHours(2))
27+
assert.Equal(t, "", SecToHours(nil)) // old behavior, empty means no output
2528
}

options/locale/locale_en-US.ini

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ webauthn_reload = Reload
5454
repository = Repository
5555
organization = Organization
5656
mirror = Mirror
57+
issue_milestone = Milestone
5758
new_repo = New Repository
5859
new_migrate = New Migration
5960
new_mirror = New Mirror
@@ -1253,6 +1254,7 @@ labels = Labels
12531254
org_labels_desc = Organization level labels that can be used with <strong>all repositories</strong> under this organization
12541255
org_labels_desc_manage = manage
12551256
1257+
milestone = Milestone
12561258
milestones = Milestones
12571259
commits = Commits
12581260
commit = Commit
@@ -2876,6 +2878,15 @@ view_as_role = View as: %s
28762878
view_as_public_hint = You are viewing the README as a public user.
28772879
view_as_member_hint = You are viewing the README as a member of this organization.
28782880
2881+
worktime = Worktime
2882+
worktime.date_range_start = Start date
2883+
worktime.date_range_end = End date
2884+
worktime.query = Query
2885+
worktime.time = Time
2886+
worktime.by_repositories = By repositories
2887+
worktime.by_milestones = By milestones
2888+
worktime.by_members = By members
2889+
28792890
[admin]
28802891
maintenance = Maintenance
28812892
dashboard = Dashboard

routers/web/org/worktime.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package org
5+
6+
import (
7+
"net/http"
8+
"time"
9+
10+
"code.gitea.io/gitea/models/organization"
11+
"code.gitea.io/gitea/modules/templates"
12+
"code.gitea.io/gitea/services/context"
13+
)
14+
15+
const tplByRepos templates.TplName = "org/worktime"
16+
17+
// parseOrgTimes contains functionality that is required in all these functions,
18+
// like parsing the date from the request, setting default dates, etc.
19+
func parseOrgTimes(ctx *context.Context) (unixFrom, unixTo int64) {
20+
rangeFrom := ctx.FormString("from")
21+
rangeTo := ctx.FormString("to")
22+
if rangeFrom == "" {
23+
rangeFrom = time.Now().Format("2006-01") + "-01" // defaults to start of current month
24+
}
25+
if rangeTo == "" {
26+
rangeTo = time.Now().Format("2006-01-02") // defaults to today
27+
}
28+
29+
ctx.Data["RangeFrom"] = rangeFrom
30+
ctx.Data["RangeTo"] = rangeTo
31+
32+
timeFrom, err := time.Parse("2006-01-02", rangeFrom)
33+
if err != nil {
34+
ctx.ServerError("time.Parse", err)
35+
}
36+
timeTo, err := time.Parse("2006-01-02", rangeTo)
37+
if err != nil {
38+
ctx.ServerError("time.Parse", err)
39+
}
40+
unixFrom = timeFrom.Unix()
41+
unixTo = timeTo.Add(1440*time.Minute - 1*time.Second).Unix() // humans expect that we include the ending day too
42+
return unixFrom, unixTo
43+
}
44+
45+
func Worktime(ctx *context.Context) {
46+
ctx.Data["PageIsOrgTimes"] = true
47+
48+
unixFrom, unixTo := parseOrgTimes(ctx)
49+
if ctx.Written() {
50+
return
51+
}
52+
53+
worktimeBy := ctx.FormString("by")
54+
ctx.Data["WorktimeBy"] = worktimeBy
55+
56+
var worktimeSumResult any
57+
var err error
58+
if worktimeBy == "milestones" {
59+
worktimeSumResult, err = organization.GetWorktimeByMilestones(ctx.Org.Organization, unixFrom, unixTo)
60+
ctx.Data["WorktimeByMilestones"] = true
61+
} else if worktimeBy == "members" {
62+
worktimeSumResult, err = organization.GetWorktimeByMembers(ctx.Org.Organization, unixFrom, unixTo)
63+
ctx.Data["WorktimeByMembers"] = true
64+
} else /* by repos */ {
65+
worktimeSumResult, err = organization.GetWorktimeByRepos(ctx.Org.Organization, unixFrom, unixTo)
66+
ctx.Data["WorktimeByRepos"] = true
67+
}
68+
if err != nil {
69+
ctx.ServerError("GetWorktime", err)
70+
return
71+
}
72+
ctx.Data["WorktimeSumResult"] = worktimeSumResult
73+
ctx.HTML(http.StatusOK, tplByRepos)
74+
}

routers/web/web.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,8 @@ func registerRoutes(m *web.Router) {
913913
m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost)
914914
m.Post("/teams/{team}/delete", org.DeleteTeam)
915915

916+
m.Get("/worktime", context.OrgAssignment(false, true), org.Worktime)
917+
916918
m.Group("/settings", func() {
917919
m.Combo("").Get(org.Settings).
918920
Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost)

services/context/org.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ func GetOrganizationByParams(ctx *Context) {
6363
}
6464

6565
// HandleOrgAssignment handles organization assignment
66+
// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin
6667
func HandleOrgAssignment(ctx *Context, args ...bool) {
6768
var (
6869
requireMember bool
@@ -269,6 +270,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) {
269270
}
270271

271272
// OrgAssignment returns a middleware to handle organization assignment
273+
// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin
272274
func OrgAssignment(args ...bool) func(ctx *Context) {
273275
return func(ctx *Context) {
274276
HandleOrgAssignment(ctx, args...)

templates/org/menu.tmpl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@
4545
</a>
4646
{{end}}
4747
{{if .IsOrganizationOwner}}
48+
<a class="{{if $.PageIsOrgTimes}}active{{end}} item" href="{{$.OrgLink}}/worktime">
49+
{{svg "octicon-clock"}} {{ctx.Locale.Tr "org.worktime"}}
50+
</a>
51+
{{end}}
52+
{{if .IsOrganizationOwner}}
4853
<span class="item-flex-space"></span>
4954
<a class="{{if .PageIsOrgSettings}}active {{end}}item" href="{{.OrgLink}}/settings">
5055
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}

templates/org/worktime.tmpl

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{{template "base/head" .}}
2+
<div class="page-content organization times">
3+
{{template "org/header" .}}
4+
<div class="ui container">
5+
<div class="ui grid">
6+
<div class="three wide column">
7+
<form class="ui form" method="get">
8+
<input type="hidden" name="by" value="{{$.WorktimeBy}}">
9+
<div class="field">
10+
<label>{{ctx.Locale.Tr "org.worktime.date_range_start"}}</label>
11+
<input type="date" name="from" value="{{.RangeFrom}}">
12+
</div>
13+
<div class="field">
14+
<label>{{ctx.Locale.Tr "org.worktime.date_range_end"}}</label>
15+
<input type="date" name="to" value="{{.RangeTo}}">
16+
</div>
17+
<button class="ui primary button">{{ctx.Locale.Tr "org.worktime.query"}}</button>
18+
</form>
19+
</div>
20+
<div class="thirteen wide column">
21+
<div class="ui column">
22+
<div class="ui compact small menu">
23+
{{$queryParams := QueryBuild "from" .RangeFrom "to" .RangeTo}}
24+
<a class="{{Iif .WorktimeByRepos "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=repos&{{$queryParams}}">{{svg "octicon-repo"}} {{ctx.Locale.Tr "org.worktime.by_repositories"}}</a>
25+
<a class="{{Iif .WorktimeByMilestones "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=milestones&{{$queryParams}}">{{svg "octicon-milestone"}} {{ctx.Locale.Tr "org.worktime.by_milestones"}}</a>
26+
<a class="{{Iif .WorktimeByMembers "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=members&{{$queryParams}}">{{svg "octicon-people"}} {{ctx.Locale.Tr "org.worktime.by_members"}}</a>
27+
</div>
28+
</div>
29+
{{if .WorktimeByRepos}}
30+
{{template "org/worktime/table_repos" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}}
31+
{{else if .WorktimeByMilestones}}
32+
{{template "org/worktime/table_milestones" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}}
33+
{{else if .WorktimeByMembers}}
34+
{{template "org/worktime/table_members" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}}
35+
{{end}}
36+
</div>
37+
</div>
38+
</div>
39+
</div>
40+
{{template "base/footer" .}}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<table class="ui table">
2+
<thead>
3+
<tr>
4+
<th>{{ctx.Locale.Tr "org.members.member"}}</th>
5+
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
6+
</tr>
7+
</thead>
8+
<tbody>
9+
{{range $.WorktimeSumResult}}
10+
<tr>
11+
<td>{{svg "octicon-person"}} <a href="{{AppSubUrl}}/{{PathEscape .UserName}}">{{.UserName}}</a></td>
12+
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
13+
</tr>
14+
{{end}}
15+
</tbody>
16+
</table>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<table class="ui table">
2+
<thead>
3+
<tr>
4+
<th>{{ctx.Locale.Tr "repository"}}</th>
5+
<th>{{ctx.Locale.Tr "repo.milestone"}}</th>
6+
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
7+
</tr>
8+
</thead>
9+
<tbody>
10+
{{range $.WorktimeSumResult}}
11+
<tr>
12+
<td>
13+
{{if not .HideRepoName}}
14+
{{svg "octicon-repo"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/issues">{{.RepoName}}</a>
15+
{{end}}
16+
</td>
17+
<td>
18+
{{if .MilestoneName}}
19+
{{svg "octicon-milestone"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/milestone/{{.MilestoneID}}">{{.MilestoneName}}</a>
20+
{{else}}
21+
-
22+
{{end}}
23+
</td>
24+
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
25+
</tr>
26+
{{end}}
27+
</tbody>
28+
</table>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<table class="ui table">
2+
<thead>
3+
<tr>
4+
<th>{{ctx.Locale.Tr "repository"}}</th>
5+
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
6+
</tr>
7+
</thead>
8+
<tbody>
9+
{{range $.WorktimeSumResult}}
10+
<tr>
11+
<td>{{svg "octicon-repo"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/issues">{{.RepoName}}</a></td>
12+
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
13+
</tr>
14+
{{end}}
15+
</tbody>
16+
</table>

0 commit comments

Comments
 (0)