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:
Skruecken
2026-10-19 11:05:10 +02:00
co-authored by Cursor
parent 41feba11de
commit bfc1cbd4d9
23 changed files with 2149 additions and 94 deletions
+59
View File
@@ -0,0 +1,59 @@
package ticket
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
var (
reHourMin = regexp.MustCompile(`(?i)^(\d+)\s*h(?:ours?)?\s*(\d+)\s*m(?:ins?)?$`)
reHourOnly = regexp.MustCompile(`(?i)^(\d+(?:\.\d+)?)\s*h(?:ours?)?$`)
reMinOnly = regexp.MustCompile(`(?i)^(\d+)\s*m(?:ins?)?$`)
reSecOnly = regexp.MustCompile(`(?i)^(\d+)\s*s(?:ecs?)?$`)
reColon = regexp.MustCompile(`^(\d+):(\d{1,2})$`)
)
// ParseDurationFlexible parses user durations like 1h30m, 90m, 1.5h, 1:30.
func ParseDurationFlexible(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("duration required")
}
// Prefer Go's parser when it works (e.g. 1h30m, 90m).
if d, err := time.ParseDuration(strings.ToLower(s)); err == nil && d > 0 {
return d, nil
}
if m := reHourMin.FindStringSubmatch(s); m != nil {
h, _ := strconv.Atoi(m[1])
min, _ := strconv.Atoi(m[2])
return time.Duration(h)*time.Hour + time.Duration(min)*time.Minute, nil
}
if m := reHourOnly.FindStringSubmatch(s); m != nil {
h, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0, fmt.Errorf("invalid duration %q", s)
}
return time.Duration(h * float64(time.Hour)), nil
}
if m := reMinOnly.FindStringSubmatch(s); m != nil {
min, _ := strconv.Atoi(m[1])
return time.Duration(min) * time.Minute, nil
}
if m := reSecOnly.FindStringSubmatch(s); m != nil {
sec, _ := strconv.Atoi(m[1])
return time.Duration(sec) * time.Second, nil
}
if m := reColon.FindStringSubmatch(s); m != nil {
h, _ := strconv.Atoi(m[1])
min, _ := strconv.Atoi(m[2])
if min >= 60 {
return 0, fmt.Errorf("invalid minutes in %q", s)
}
return time.Duration(h)*time.Hour + time.Duration(min)*time.Minute, nil
}
return 0, fmt.Errorf("invalid duration %q (try 1h30m, 90m, 1.5h)", s)
}