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>
106 lines
2.6 KiB
Go
106 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|