Skip to content

Worktime tracking for the organization level #19808

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Feb 2, 2025
103 changes: 103 additions & 0 deletions models/organization/org_worktime.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package organization

import (
"sort"

"code.gitea.io/gitea/models/db"

"xorm.io/builder"
)

type WorktimeSumByRepos struct {
RepoName string
SumTime int64
}

func GetWorktimeByRepos(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByRepos, err error) {
err = db.GetEngine(db.DefaultContext).
Select("repository.name AS repo_name, SUM(tracked_time.time) AS sum_time").
Table("tracked_time").
Join("INNER", "issue", "tracked_time.issue_id = issue.id").
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}).
GroupBy("repository.name").
OrderBy("repository.name").
Find(&results)
return results, err
}

type WorktimeSumByMilestones struct {
RepoName string
MilestoneName string
MilestoneID int64
MilestoneDeadline int64
SumTime int64
HideRepoName bool
}

func GetWorktimeByMilestones(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMilestones, err error) {
err = db.GetEngine(db.DefaultContext).
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").
Table("tracked_time").
Join("INNER", "issue", "tracked_time.issue_id = issue.id").
Join("INNER", "repository", "issue.repo_id = repository.id").
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}).
GroupBy("repository.name, milestone.name, milestone.deadline_unix, milestone.id").
OrderBy("repository.name, milestone.deadline_unix, milestone.id").
Find(&results)

// TODO: pgsql: NULL values are sorted last in default ascending order, so we need to sort them manually again.
sort.Slice(results, func(i, j int) bool {
if results[i].RepoName != results[j].RepoName {
return results[i].RepoName < results[j].RepoName
}
if results[i].MilestoneDeadline != results[j].MilestoneDeadline {
return results[i].MilestoneDeadline < results[j].MilestoneDeadline
}
return results[i].MilestoneID < results[j].MilestoneID
})

// Show only the first RepoName, for nicer output.
prevRepoName := ""
for i := 0; i < len(results); i++ {
res := &results[i]
res.MilestoneDeadline = 0 // clear the deadline because we do not really need it
if prevRepoName == res.RepoName {
res.HideRepoName = true
}
prevRepoName = res.RepoName
}
return results, err
}

type WorktimeSumByMembers struct {
UserName string
SumTime int64
}

func GetWorktimeByMembers(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMembers, err error) {
err = db.GetEngine(db.DefaultContext).
Select("`user`.name AS user_name, SUM(tracked_time.time) AS sum_time").
Table("tracked_time").
Join("INNER", "issue", "tracked_time.issue_id = issue.id").
Join("INNER", "repository", "issue.repo_id = repository.id").
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}).
GroupBy("`user`.name").
OrderBy("sum_time DESC").
Find(&results)
return results, err
}
2 changes: 1 addition & 1 deletion modules/templates/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func NewFuncMap() template.FuncMap {
// time / number / format
"FileSize": base.FileSize,
"CountFmt": countFmt,
"Sec2Time": util.SecToHours,
"Sec2Hour": util.SecToHours,

"TimeEstimateString": timeEstimateString,

Expand Down
10 changes: 7 additions & 3 deletions modules/util/sec_to_time.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,20 @@ import (
// SecToHours converts an amount of seconds to a human-readable hours string.
// This is stable for planning and managing timesheets.
// Here it only supports hours and minutes, because a work day could contain 6 or 7 or 8 hours.
// If the duration is less than 1 minute, it will be shown as seconds.
func SecToHours(durationVal any) string {
duration, _ := ToInt64(durationVal)
hours := duration / 3600
minutes := (duration / 60) % 60
seconds, _ := ToInt64(durationVal)
hours := seconds / 3600
minutes := (seconds / 60) % 60

formattedTime := ""
formattedTime = formatTime(hours, "hour", formattedTime)
formattedTime = formatTime(minutes, "minute", formattedTime)

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

Expand Down
3 changes: 3 additions & 0 deletions modules/util/sec_to_time_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ func TestSecToHours(t *testing.T) {
assert.Equal(t, "156 hours 30 minutes", SecToHours(6*day+12*hour+30*minute+18*second))
assert.Equal(t, "98 hours 16 minutes", SecToHours(4*day+2*hour+16*minute+58*second))
assert.Equal(t, "672 hours", SecToHours(4*7*day))
assert.Equal(t, "1 second", SecToHours(1))
assert.Equal(t, "2 seconds", SecToHours(2))
assert.Equal(t, "", SecToHours(nil)) // old behavior, empty means no output
}
11 changes: 11 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ webauthn_reload = Reload
repository = Repository
organization = Organization
mirror = Mirror
issue_milestone = Milestone
new_repo = New Repository
new_migrate = New Migration
new_mirror = New Mirror
Expand Down Expand Up @@ -1253,6 +1254,7 @@ labels = Labels
org_labels_desc = Organization level labels that can be used with <strong>all repositories</strong> under this organization
org_labels_desc_manage = manage

milestone = Milestone
milestones = Milestones
commits = Commits
commit = Commit
Expand Down Expand Up @@ -2876,6 +2878,15 @@ view_as_role = View as: %s
view_as_public_hint = You are viewing the README as a public user.
view_as_member_hint = You are viewing the README as a member of this organization.

worktime = Worktime
worktime.date_range_start = Start date
worktime.date_range_end = End date
worktime.query = Query
worktime.time = Time
worktime.by_repositories = By repositories
worktime.by_milestones = By milestones
worktime.by_members = By members

[admin]
maintenance = Maintenance
dashboard = Dashboard
Expand Down
74 changes: 74 additions & 0 deletions routers/web/org/worktime.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package org

import (
"net/http"
"time"

"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
)

const tplByRepos templates.TplName = "org/worktime"

// parseOrgTimes contains functionality that is required in all these functions,
// like parsing the date from the request, setting default dates, etc.
func parseOrgTimes(ctx *context.Context) (unixFrom, unixTo int64) {
rangeFrom := ctx.FormString("from")
rangeTo := ctx.FormString("to")
if rangeFrom == "" {
rangeFrom = time.Now().Format("2006-01") + "-01" // defaults to start of current month
}
if rangeTo == "" {
rangeTo = time.Now().Format("2006-01-02") // defaults to today
}

ctx.Data["RangeFrom"] = rangeFrom
ctx.Data["RangeTo"] = rangeTo

timeFrom, err := time.Parse("2006-01-02", rangeFrom)
if err != nil {
ctx.ServerError("time.Parse", err)
}
timeTo, err := time.Parse("2006-01-02", rangeTo)
if err != nil {
ctx.ServerError("time.Parse", err)
}
unixFrom = timeFrom.Unix()
unixTo = timeTo.Add(1440*time.Minute - 1*time.Second).Unix() // humans expect that we include the ending day too
return unixFrom, unixTo
}

func Worktime(ctx *context.Context) {
ctx.Data["PageIsOrgTimes"] = true

unixFrom, unixTo := parseOrgTimes(ctx)
if ctx.Written() {
return
}

worktimeBy := ctx.FormString("by")
ctx.Data["WorktimeBy"] = worktimeBy

var worktimeSumResult any
var err error
if worktimeBy == "milestones" {
worktimeSumResult, err = organization.GetWorktimeByMilestones(ctx.Org.Organization, unixFrom, unixTo)
ctx.Data["WorktimeByMilestones"] = true
} else if worktimeBy == "members" {
worktimeSumResult, err = organization.GetWorktimeByMembers(ctx.Org.Organization, unixFrom, unixTo)
ctx.Data["WorktimeByMembers"] = true
} else /* by repos */ {
worktimeSumResult, err = organization.GetWorktimeByRepos(ctx.Org.Organization, unixFrom, unixTo)
ctx.Data["WorktimeByRepos"] = true
}
if err != nil {
ctx.ServerError("GetWorktime", err)
return
}
ctx.Data["WorktimeSumResult"] = worktimeSumResult
ctx.HTML(http.StatusOK, tplByRepos)
}
2 changes: 2 additions & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,8 @@ func registerRoutes(m *web.Router) {
m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost)
m.Post("/teams/{team}/delete", org.DeleteTeam)

m.Get("/worktime", context.OrgAssignment(false, true), org.Worktime)

m.Group("/settings", func() {
m.Combo("").Get(org.Settings).
Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost)
Expand Down
2 changes: 2 additions & 0 deletions services/context/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func GetOrganizationByParams(ctx *Context) {
}

// HandleOrgAssignment handles organization assignment
// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin
func HandleOrgAssignment(ctx *Context, args ...bool) {
var (
requireMember bool
Expand Down Expand Up @@ -269,6 +270,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) {
}

// OrgAssignment returns a middleware to handle organization assignment
// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin
func OrgAssignment(args ...bool) func(ctx *Context) {
return func(ctx *Context) {
HandleOrgAssignment(ctx, args...)
Expand Down
5 changes: 5 additions & 0 deletions templates/org/menu.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
</a>
{{end}}
{{if .IsOrganizationOwner}}
<a class="{{if $.PageIsOrgTimes}}active{{end}} item" href="{{$.OrgLink}}/worktime">
{{svg "octicon-clock"}} {{ctx.Locale.Tr "org.worktime"}}
</a>
{{end}}
{{if .IsOrganizationOwner}}
<span class="item-flex-space"></span>
<a class="{{if .PageIsOrgSettings}}active {{end}}item" href="{{.OrgLink}}/settings">
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
Expand Down
40 changes: 40 additions & 0 deletions templates/org/worktime.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{{template "base/head" .}}
<div class="page-content organization times">
{{template "org/header" .}}
<div class="ui container">
<div class="ui grid">
<div class="three wide column">
<form class="ui form" method="get">
<input type="hidden" name="by" value="{{$.WorktimeBy}}">
<div class="field">
<label>{{ctx.Locale.Tr "org.worktime.date_range_start"}}</label>
<input type="date" name="from" value="{{.RangeFrom}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.worktime.date_range_end"}}</label>
<input type="date" name="to" value="{{.RangeTo}}">
</div>
<button class="ui primary button">{{ctx.Locale.Tr "org.worktime.query"}}</button>
</form>
</div>
<div class="thirteen wide column">
<div class="ui column">
<div class="ui compact small menu">
{{$queryParams := QueryBuild "from" .RangeFrom "to" .RangeTo}}
<a class="{{Iif .WorktimeByRepos "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=repos&{{$queryParams}}">{{svg "octicon-repo"}} {{ctx.Locale.Tr "org.worktime.by_repositories"}}</a>
<a class="{{Iif .WorktimeByMilestones "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=milestones&{{$queryParams}}">{{svg "octicon-milestone"}} {{ctx.Locale.Tr "org.worktime.by_milestones"}}</a>
<a class="{{Iif .WorktimeByMembers "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=members&{{$queryParams}}">{{svg "octicon-people"}} {{ctx.Locale.Tr "org.worktime.by_members"}}</a>
</div>
</div>
{{if .WorktimeByRepos}}
{{template "org/worktime/table_repos" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}}
{{else if .WorktimeByMilestones}}
{{template "org/worktime/table_milestones" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}}
{{else if .WorktimeByMembers}}
{{template "org/worktime/table_members" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}}
{{end}}
</div>
</div>
</div>
</div>
{{template "base/footer" .}}
16 changes: 16 additions & 0 deletions templates/org/worktime/table_members.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<table class="ui table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "org.members.member"}}</th>
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
</tr>
</thead>
<tbody>
{{range $.WorktimeSumResult}}
<tr>
<td>{{svg "octicon-person"}} <a href="{{AppSubUrl}}/{{PathEscape .UserName}}">{{.UserName}}</a></td>
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
</tr>
{{end}}
</tbody>
</table>
28 changes: 28 additions & 0 deletions templates/org/worktime/table_milestones.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<table class="ui table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repository"}}</th>
<th>{{ctx.Locale.Tr "repo.milestone"}}</th>
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
</tr>
</thead>
<tbody>
{{range $.WorktimeSumResult}}
<tr>
<td>
{{if not .HideRepoName}}
{{svg "octicon-repo"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/issues">{{.RepoName}}</a>
{{end}}
</td>
<td>
{{if .MilestoneName}}
{{svg "octicon-milestone"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/milestone/{{.MilestoneID}}">{{.MilestoneName}}</a>
{{else}}
-
{{end}}
</td>
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
</tr>
{{end}}
</tbody>
</table>
16 changes: 16 additions & 0 deletions templates/org/worktime/table_repos.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<table class="ui table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repository"}}</th>
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
</tr>
</thead>
<tbody>
{{range $.WorktimeSumResult}}
<tr>
<td>{{svg "octicon-repo"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/issues">{{.RepoName}}</a></td>
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
</tr>
{{end}}
</tbody>
</table>
Loading