Add ticket time tracking with reports and entry management.
Support start/stop timer, manual durations, grouped reports, and per-ticket edit/delete of time entries with undo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceTimer = "timer"
|
||||
SourceManual = "manual"
|
||||
)
|
||||
|
||||
// TimeEntry is a recorded block of work on a ticket.
|
||||
type TimeEntry struct {
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON encodes Duration as nanoseconds for stable JSON.
|
||||
func (e TimeEntry) MarshalJSON() ([]byte, error) {
|
||||
type alias struct {
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Duration int64 `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
return json.Marshal(alias{
|
||||
ID: e.ID,
|
||||
TicketID: e.TicketID,
|
||||
ProjectID: e.ProjectID,
|
||||
Start: e.Start,
|
||||
End: e.End,
|
||||
Duration: int64(e.Duration),
|
||||
Source: e.Source,
|
||||
Note: e.Note,
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes Duration from nanoseconds.
|
||||
func (e *TimeEntry) UnmarshalJSON(data []byte) error {
|
||||
type alias struct {
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Duration int64 `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
e.ID = a.ID
|
||||
e.TicketID = a.TicketID
|
||||
e.ProjectID = a.ProjectID
|
||||
e.Start = a.Start
|
||||
e.End = a.End
|
||||
e.Duration = time.Duration(a.Duration)
|
||||
e.Source = a.Source
|
||||
e.Note = a.Note
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActiveTimer is a running stopwatch persisted separately until stop.
|
||||
type ActiveTimer struct {
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
}
|
||||
|
||||
// FormatDurationHuman returns a compact duration like 2h15m or 45m.
|
||||
func FormatDurationHuman(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
d = d.Round(time.Second)
|
||||
h := d / time.Hour
|
||||
m := (d % time.Hour) / time.Minute
|
||||
s := (d % time.Minute) / time.Second
|
||||
switch {
|
||||
case h > 0 && m > 0:
|
||||
return fmt.Sprintf("%dh%dm", h, m)
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%dh", h)
|
||||
case m > 0 && s > 0 && m < 5:
|
||||
return fmt.Sprintf("%dm%ds", m, s)
|
||||
case m > 0:
|
||||
return fmt.Sprintf("%dm", m)
|
||||
default:
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user