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
+3 -2
View File
@@ -43,16 +43,17 @@ func run() error {
} }
store := ticket.NewJSONStore(cfg.DataDir) store := ticket.NewJSONStore(cfg.DataDir)
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
projects := project.NewDirStore(cfg.DataDir) projects := project.NewDirStore(cfg.DataDir)
if err := projects.Ensure(cfg.ActiveProject); err != nil { if err := projects.Ensure(cfg.ActiveProject); err != nil {
return fmt.Errorf("ensure project: %w", err) return fmt.Errorf("ensure project: %w", err)
} }
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store) history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore)
if err != nil { if err != nil {
return fmt.Errorf("history: %w", err) return fmt.Errorf("history: %w", err)
} }
model := ui.New(cfg, store, projects, history) model := ui.New(cfg, store, projects, timeStore, history)
prog := tea.NewProgram(model, tea.WithAltScreen()) prog := tea.NewProgram(model, tea.WithAltScreen())
if _, err := prog.Run(); err != nil { if _, err := prog.Run(); err != nil {
return fmt.Errorf("tui: %w", err) return fmt.Errorf("tui: %w", err)
+5 -2
View File
@@ -26,6 +26,7 @@ type History struct {
redoS []Command redoS []Command
path string path string
store ticket.Store store ticket.Store
timeStore ticket.TimeStore
max int max int
} }
@@ -36,13 +37,15 @@ func NewHistory() *History {
// OpenHistory loads a persisted history from path (DataDir/history.json) or // OpenHistory loads a persisted history from path (DataDir/history.json) or
// starts empty if the file is missing. Subsequent Do/Undo/Redo write back. // starts empty if the file is missing. Subsequent Do/Undo/Redo write back.
func OpenHistory(path string, store ticket.Store) (*History, error) { // timeStore may be nil if time-entry commands are unused.
func OpenHistory(path string, store ticket.Store, timeStore ticket.TimeStore) (*History, error) {
h := &History{ h := &History{
path: path, path: path,
store: store, store: store,
timeStore: timeStore,
max: MaxHistory, max: MaxHistory,
} }
undo, redo, err := loadHistoryFile(path, store) undo, redo, err := loadHistoryFile(path, store, timeStore)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+49 -6
View File
@@ -26,12 +26,18 @@ type persistedCmd struct {
Snapshot *ticket.Ticket `json:"snapshot,omitempty"` Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
Before *ticket.Ticket `json:"before,omitempty"` Before *ticket.Ticket `json:"before,omitempty"`
After *ticket.Ticket `json:"after,omitempty"` After *ticket.Ticket `json:"after,omitempty"`
TimeEntry *ticket.TimeEntry `json:"timeEntry,omitempty"`
TimeBefore *ticket.TimeEntry `json:"timeBefore,omitempty"`
TimeAfter *ticket.TimeEntry `json:"timeAfter,omitempty"`
} }
const ( const (
typeCreate = "create" typeCreate = "create"
typeDelete = "delete" typeDelete = "delete"
typeUpdate = "update" typeUpdate = "update"
typeAddTime = "add_time"
typeRemoveTime = "remove_time"
typeUpdateTime = "update_time"
) )
// HistoryPath returns DataDir/history.json. // HistoryPath returns DataDir/history.json.
@@ -54,12 +60,25 @@ func encodeCmd(cmd Command) (persistedCmd, error) {
case *UpdateTicket: case *UpdateTicket:
b, a := c.Before, c.After b, a := c.Before, c.After
return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil
case *AddTimeEntry:
e := c.Entry
if c.created.ID != "" {
e = c.created
}
cp := e
return persistedCmd{Type: typeAddTime, TimeEntry: &cp}, nil
case *RemoveTimeEntry:
cp := c.Snapshot
return persistedCmd{Type: typeRemoveTime, TimeEntry: &cp}, nil
case *UpdateTimeEntry:
b, a := c.Before, c.After
return persistedCmd{Type: typeUpdateTime, TimeBefore: &b, TimeAfter: &a}, nil
default: default:
return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd) return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd)
} }
} }
func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) { func decodeCmd(rec persistedCmd, store ticket.Store, timeStore ticket.TimeStore) (Command, error) {
switch rec.Type { switch rec.Type {
case typeCreate: case typeCreate:
if rec.Ticket == nil { if rec.Ticket == nil {
@@ -76,6 +95,30 @@ func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
return nil, fmt.Errorf("update record missing before/after") return nil, fmt.Errorf("update record missing before/after")
} }
return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil
case typeAddTime:
if rec.TimeEntry == nil {
return nil, fmt.Errorf("add_time record missing entry")
}
if timeStore == nil {
return nil, fmt.Errorf("add_time: time store not configured")
}
return &AddTimeEntry{Store: timeStore, Entry: *rec.TimeEntry, created: *rec.TimeEntry}, nil
case typeRemoveTime:
if rec.TimeEntry == nil {
return nil, fmt.Errorf("remove_time record missing entry")
}
if timeStore == nil {
return nil, fmt.Errorf("remove_time: time store not configured")
}
return &RemoveTimeEntry{Store: timeStore, Snapshot: *rec.TimeEntry}, nil
case typeUpdateTime:
if rec.TimeBefore == nil || rec.TimeAfter == nil {
return nil, fmt.Errorf("update_time record missing before/after")
}
if timeStore == nil {
return nil, fmt.Errorf("update_time: time store not configured")
}
return &UpdateTimeEntry{Store: timeStore, Before: *rec.TimeBefore, After: *rec.TimeAfter}, nil
default: default:
return nil, fmt.Errorf("unknown command type %q", rec.Type) return nil, fmt.Errorf("unknown command type %q", rec.Type)
} }
@@ -93,10 +136,10 @@ func encodeStack(cmds []Command) ([]persistedCmd, error) {
return out, nil return out, nil
} }
func decodeStack(recs []persistedCmd, store ticket.Store) ([]Command, error) { func decodeStack(recs []persistedCmd, store ticket.Store, timeStore ticket.TimeStore) ([]Command, error) {
out := make([]Command, 0, len(recs)) out := make([]Command, 0, len(recs))
for _, rec := range recs { for _, rec := range recs {
cmd, err := decodeCmd(rec, store) cmd, err := decodeCmd(rec, store, timeStore)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -154,7 +197,7 @@ func (h *History) persistLocked() error {
return nil return nil
} }
func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err error) { func loadHistoryFile(path string, store ticket.Store, timeStore ticket.TimeStore) (undo, redo []Command, err error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -169,11 +212,11 @@ func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err
if err := json.Unmarshal(data, &file); err != nil { if err := json.Unmarshal(data, &file); err != nil {
return nil, nil, fmt.Errorf("parse history: %w", err) return nil, nil, fmt.Errorf("parse history: %w", err)
} }
undo, err = decodeStack(file.Undo, store) undo, err = decodeStack(file.Undo, store, timeStore)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
redo, err = decodeStack(file.Redo, store) redo, err = decodeStack(file.Redo, store, timeStore)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
+3 -3
View File
@@ -14,7 +14,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
store := ticket.NewJSONStore(dir) store := ticket.NewJSONStore(dir)
path := command.HistoryPath(dir) path := command.HistoryPath(dir)
h1, err := command.OpenHistory(path, store) h1, err := command.OpenHistory(path, store, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -30,7 +30,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
t.Fatalf("history file missing: %v", err) t.Fatalf("history file missing: %v", err)
} }
h2, err := command.OpenHistory(path, store) h2, err := command.OpenHistory(path, store, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -49,7 +49,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
func TestHistoryCapsAtMax(t *testing.T) { func TestHistoryCapsAtMax(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
store := ticket.NewJSONStore(dir) store := ticket.NewJSONStore(dir)
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store) h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+81
View File
@@ -0,0 +1,81 @@
package command
import (
"context"
"fmt"
"task/internal/ticket"
)
// AddTimeEntry creates a time entry; Undo deletes it.
type AddTimeEntry struct {
Store ticket.TimeStore
Entry ticket.TimeEntry
created ticket.TimeEntry
}
func (c *AddTimeEntry) Name() string { return "add time entry" }
func (c *AddTimeEntry) Execute(ctx context.Context) error {
_ = ctx
created, err := c.Store.Create(c.Entry)
if err != nil {
return err
}
c.created = created
c.Entry = created
return nil
}
func (c *AddTimeEntry) Undo(ctx context.Context) error {
_ = ctx
id := c.created.ID
if id == "" {
id = c.Entry.ID
}
if id == "" {
return fmt.Errorf("add time undo: missing entry id")
}
return c.Store.Delete(id)
}
// RemoveTimeEntry deletes a time entry; Undo restores the snapshot.
type RemoveTimeEntry struct {
Store ticket.TimeStore
Snapshot ticket.TimeEntry
}
func (c *RemoveTimeEntry) Name() string { return "remove time entry" }
func (c *RemoveTimeEntry) Execute(ctx context.Context) error {
_ = ctx
if c.Snapshot.ID == "" {
return fmt.Errorf("remove time: missing entry id")
}
return c.Store.Delete(c.Snapshot.ID)
}
func (c *RemoveTimeEntry) Undo(ctx context.Context) error {
_ = ctx
_, err := c.Store.Create(c.Snapshot)
return err
}
// UpdateTimeEntry applies a new entry state; Undo restores Before.
type UpdateTimeEntry struct {
Store ticket.TimeStore
Before ticket.TimeEntry
After ticket.TimeEntry
}
func (c *UpdateTimeEntry) Name() string { return "update time entry" }
func (c *UpdateTimeEntry) Execute(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.After)
}
func (c *UpdateTimeEntry) Undo(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.Before)
}
+75
View File
@@ -0,0 +1,75 @@
package command_test
import (
"testing"
"time"
"task/internal/command"
"task/internal/ticket"
)
func TestUpdateTimeEntryUndo(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONTimeStore(dir)
h := command.NewHistory()
created, err := store.Create(ticket.TimeEntry{
TicketID: "t1",
ProjectID: "inbox",
Start: time.Now().UTC().Add(-time.Hour),
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
after := created
after.Duration = 30 * time.Minute
after.End = after.Start.Add(after.Duration)
if err := h.Do(&command.UpdateTimeEntry{Store: store, Before: created, After: after}); err != nil {
t.Fatal(err)
}
list, _ := store.ListByTicket("t1")
if len(list) != 1 || list[0].Duration != 30*time.Minute {
t.Fatalf("after update: %+v", list)
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, _ = store.ListByTicket("t1")
if len(list) != 1 || list[0].Duration != time.Hour {
t.Fatalf("after undo: %+v", list)
}
}
func TestRemoveTimeEntryUndo(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONTimeStore(dir)
h := command.NewHistory()
created, err := store.Create(ticket.TimeEntry{
TicketID: "t1",
ProjectID: "inbox",
Start: time.Now().UTC(),
Duration: 15 * time.Minute,
Source: ticket.SourceTimer,
})
if err != nil {
t.Fatal(err)
}
if err := h.Do(&command.RemoveTimeEntry{Store: store, Snapshot: created}); err != nil {
t.Fatal(err)
}
list, _ := store.List()
if len(list) != 0 {
t.Fatalf("expected empty after remove, got %d", len(list))
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, _ = store.List()
if len(list) != 1 || list[0].ID != created.ID {
t.Fatalf("after undo: %+v", list)
}
}
+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)
}
+58
View File
@@ -0,0 +1,58 @@
package ticket_test
import (
"testing"
"time"
"task/internal/ticket"
)
func TestParseDurationFlexible(t *testing.T) {
cases := []struct {
in string
want time.Duration
}{
{"1h30m", 90 * time.Minute},
{"90m", 90 * time.Minute},
{"1.5h", 90 * time.Minute},
{"2h", 2 * time.Hour},
{"45m", 45 * time.Minute},
{"30s", 30 * time.Second},
{"1:30", 90 * time.Minute},
{" 1h ", time.Hour},
}
for _, tc := range cases {
got, err := ticket.ParseDurationFlexible(tc.in)
if err != nil {
t.Fatalf("%q: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("%q: got %v want %v", tc.in, got, tc.want)
}
}
}
func TestParseDurationFlexibleRejects(t *testing.T) {
for _, in := range []string{"", "abc", "1:99", "-5m"} {
if _, err := ticket.ParseDurationFlexible(in); err == nil {
t.Fatalf("%q: expected error", in)
}
}
}
func TestFormatDurationHuman(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{2*time.Hour + 15*time.Minute, "2h15m"},
{3 * time.Hour, "3h"},
{45 * time.Minute, "45m"},
{30 * time.Second, "30s"},
}
for _, tc := range cases {
if got := ticket.FormatDurationHuman(tc.d); got != tc.want {
t.Fatalf("%v: got %q want %q", tc.d, got, tc.want)
}
}
}
+282
View File
@@ -0,0 +1,282 @@
package ticket
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
)
const (
timeEntriesFileName = "time_entries.json"
activeTimerFileName = "active_timer.json"
)
// JSONTimeStore persists time entries and the active timer under DataDir.
type JSONTimeStore struct {
dataDir string
mu sync.Mutex
}
// NewJSONTimeStore creates a time store rooted at dataDir.
func NewJSONTimeStore(dataDir string) *JSONTimeStore {
return &JSONTimeStore{dataDir: dataDir}
}
func (s *JSONTimeStore) entriesPath() string {
return filepath.Join(s.dataDir, timeEntriesFileName)
}
func (s *JSONTimeStore) timerPath() string {
return filepath.Join(s.dataDir, activeTimerFileName)
}
// List returns all time entries.
func (s *JSONTimeStore) List() ([]TimeEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.readUnlocked()
}
// ListByTicket returns entries for one ticket.
func (s *JSONTimeStore) ListByTicket(ticketID string) ([]TimeEntry, error) {
all, err := s.List()
if err != nil {
return nil, err
}
out := make([]TimeEntry, 0)
for _, e := range all {
if e.TicketID == ticketID {
out = append(out, e)
}
}
return out, nil
}
// Create adds a time entry, assigning an ID if missing.
func (s *JSONTimeStore) Create(e TimeEntry) (TimeEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
if e.TicketID == "" {
return TimeEntry{}, fmt.Errorf("ticketId is required")
}
if e.Duration <= 0 && !e.End.IsZero() && !e.Start.IsZero() {
e.Duration = e.End.Sub(e.Start)
}
if e.Duration <= 0 {
return TimeEntry{}, fmt.Errorf("duration must be positive")
}
if e.ID == "" {
e.ID = uuid.NewString()
}
if e.Source == "" {
e.Source = SourceManual
}
if e.Start.IsZero() {
e.Start = time.Now().UTC()
}
if e.End.IsZero() {
e.End = e.Start.Add(e.Duration)
}
entries, err := s.readUnlocked()
if err != nil {
return TimeEntry{}, err
}
entries = append(entries, e)
if err := s.writeUnlocked(entries); err != nil {
return TimeEntry{}, err
}
return e, nil
}
// Update replaces an existing time entry by ID.
func (s *JSONTimeStore) Update(e TimeEntry) error {
s.mu.Lock()
defer s.mu.Unlock()
if e.ID == "" {
return fmt.Errorf("time entry id is required")
}
if e.Duration <= 0 {
return fmt.Errorf("duration must be positive")
}
if e.Start.IsZero() {
e.Start = time.Now().UTC()
}
e.End = e.Start.Add(e.Duration)
entries, err := s.readUnlocked()
if err != nil {
return err
}
found := false
for i := range entries {
if entries[i].ID == e.ID {
entries[i] = e
found = true
break
}
}
if !found {
return fmt.Errorf("time entry %s: not found", e.ID)
}
return s.writeUnlocked(entries)
}
// Delete removes a time entry by ID.
func (s *JSONTimeStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := s.readUnlocked()
if err != nil {
return err
}
out := make([]TimeEntry, 0, len(entries))
found := false
for _, e := range entries {
if e.ID == id {
found = true
continue
}
out = append(out, e)
}
if !found {
return fmt.Errorf("time entry %s: not found", id)
}
return s.writeUnlocked(out)
}
// SumByTicket returns total duration for a ticket.
func (s *JSONTimeStore) SumByTicket(ticketID string) (time.Duration, error) {
entries, err := s.ListByTicket(ticketID)
if err != nil {
return 0, err
}
var sum time.Duration
for _, e := range entries {
sum += e.Duration
}
return sum, nil
}
// LoadActiveTimer returns the running timer, or nil if none.
func (s *JSONTimeStore) LoadActiveTimer() (*ActiveTimer, error) {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.timerPath())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read active timer: %w", err)
}
if len(data) == 0 {
return nil, nil
}
var t ActiveTimer
if err := json.Unmarshal(data, &t); err != nil {
return nil, fmt.Errorf("parse active timer: %w", err)
}
if t.TicketID == "" {
return nil, nil
}
return &t, nil
}
// SaveActiveTimer writes the active timer, or removes the file when t is nil.
func (s *JSONTimeStore) SaveActiveTimer(t *ActiveTimer) error {
s.mu.Lock()
defer s.mu.Unlock()
path := s.timerPath()
if t == nil {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("clear active timer: %w", err)
}
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create data dir: %w", err)
}
data, err := json.MarshalIndent(t, "", " ")
if err != nil {
return fmt.Errorf("marshal active timer: %w", err)
}
data = append(data, '\n')
tmp, err := os.CreateTemp(filepath.Dir(path), "timer-*.tmp")
if err != nil {
return fmt.Errorf("create temp timer: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp timer: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp timer: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename timer: %w", err)
}
return nil
}
func (s *JSONTimeStore) readUnlocked() ([]TimeEntry, error) {
path := s.entriesPath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []TimeEntry{}, nil
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(data) == 0 {
return []TimeEntry{}, nil
}
var entries []TimeEntry
if err := json.Unmarshal(data, &entries); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if entries == nil {
entries = []TimeEntry{}
}
return entries, nil
}
func (s *JSONTimeStore) writeUnlocked(entries []TimeEntry) error {
path := s.entriesPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create data dir: %w", err)
}
data, err := json.MarshalIndent(entries, "", " ")
if err != nil {
return fmt.Errorf("marshal time entries: %w", err)
}
data = append(data, '\n')
tmp, err := os.CreateTemp(filepath.Dir(path), "time-*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename temp to %s: %w", path, err)
}
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package ticket_test
import (
"path/filepath"
"testing"
"time"
"task/internal/ticket"
)
func TestJSONTimeStoreRoundTrip(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONTimeStore(dir)
created, err := store.Create(ticket.TimeEntry{
TicketID: "t1",
ProjectID: "inbox",
Start: time.Now().UTC().Add(-time.Hour),
End: time.Now().UTC(),
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
if created.ID == "" {
t.Fatal("expected id")
}
list, err := store.List()
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
sum, err := store.SumByTicket("t1")
if err != nil || sum != time.Hour {
t.Fatalf("sum: %v %v", err, sum)
}
timer := &ticket.ActiveTimer{TicketID: "t1", ProjectID: "inbox", StartedAt: time.Now().UTC()}
if err := store.SaveActiveTimer(timer); err != nil {
t.Fatal(err)
}
loaded, err := store.LoadActiveTimer()
if err != nil || loaded == nil || loaded.TicketID != "t1" {
t.Fatalf("load timer: %v %+v", err, loaded)
}
if err := store.SaveActiveTimer(nil); err != nil {
t.Fatal(err)
}
if loaded, err = store.LoadActiveTimer(); err != nil || loaded != nil {
t.Fatalf("cleared timer: %v %+v", err, loaded)
}
updated := created
updated.Duration = 90 * time.Minute
if err := store.Update(updated); err != nil {
t.Fatal(err)
}
list, err = store.ListByTicket("t1")
if err != nil || len(list) != 1 || list[0].Duration != 90*time.Minute {
t.Fatalf("after update: %v %+v", err, list)
}
if err := store.Delete(created.ID); err != nil {
t.Fatal(err)
}
list, err = store.List()
if err != nil || len(list) != 0 {
t.Fatalf("after delete: %v len=%d path=%s", err, len(list), filepath.Join(dir, "time_entries.json"))
}
}
+157
View File
@@ -0,0 +1,157 @@
package ticket
import (
"fmt"
"sort"
"time"
)
// GroupBy selects how Aggregate buckets time entries.
type GroupBy string
const (
GroupByProject GroupBy = "project"
GroupByStatus GroupBy = "status"
GroupByTicket GroupBy = "ticket"
GroupByDay GroupBy = "day"
)
// AllGroupBys is the ordered list of report grouping options.
var AllGroupBys = []GroupBy{
GroupByProject,
GroupByStatus,
GroupByTicket,
GroupByDay,
}
// Label returns a human-readable name for the grouping.
func (g GroupBy) Label() string {
switch g {
case GroupByProject:
return "Project"
case GroupByStatus:
return "Status"
case GroupByTicket:
return "Ticket"
case GroupByDay:
return "Day"
default:
return string(g)
}
}
// Shortcut returns the jump letter for the grouping picker.
func (g GroupBy) Shortcut() rune {
switch g {
case GroupByProject:
return 'p'
case GroupByStatus:
return 's'
case GroupByTicket:
return 't'
case GroupByDay:
return 'd'
default:
return 0
}
}
// Index returns the position in AllGroupBys, or 0 if unknown.
func (g GroupBy) Index() int {
for i, x := range AllGroupBys {
if x == g {
return i
}
}
return 0
}
// GroupRow is one aggregated bucket in a time report.
type GroupRow struct {
Key string
Label string
Duration time.Duration
}
// Aggregate sums entry durations grouped by groupBy.
// ticketsByID is used for status/ticket labels; missing tickets fall back to IDs.
func Aggregate(entries []TimeEntry, ticketsByID map[string]Ticket, groupBy GroupBy) []GroupRow {
sums := map[string]time.Duration{}
labels := map[string]string{}
for _, e := range entries {
key, label := groupKeyLabel(e, ticketsByID, groupBy)
sums[key] += e.Duration
if _, ok := labels[key]; !ok {
labels[key] = label
}
}
rows := make([]GroupRow, 0, len(sums))
for key, d := range sums {
rows = append(rows, GroupRow{Key: key, Label: labels[key], Duration: d})
}
sort.Slice(rows, func(i, j int) bool {
if groupBy == GroupByDay {
return rows[i].Key > rows[j].Key // newest day first
}
if rows[i].Duration != rows[j].Duration {
return rows[i].Duration > rows[j].Duration
}
return rows[i].Label < rows[j].Label
})
return rows
}
func groupKeyLabel(e TimeEntry, ticketsByID map[string]Ticket, groupBy GroupBy) (key, label string) {
t, ok := ticketsByID[e.TicketID]
switch groupBy {
case GroupByProject:
key = e.ProjectID
if key == "" && ok {
key = t.ProjectID
}
if key == "" {
key = "(none)"
}
return key, key
case GroupByStatus:
if ok {
key = string(t.Status)
} else {
key = "(unknown)"
}
return key, key
case GroupByTicket:
key = e.TicketID
if ok && t.Title != "" {
return key, t.Title
}
if key == "" {
return "(none)", "(none)"
}
return key, key
case GroupByDay:
start := e.Start
if start.IsZero() {
start = e.End
}
if start.IsZero() {
return "(unknown)", "(unknown)"
}
local := start.Local()
key = local.Format("2006-01-02")
return key, key
default:
return fmt.Sprintf("%v", groupBy), string(groupBy)
}
}
// TotalDuration sums all entry durations.
func TotalDuration(entries []TimeEntry) time.Duration {
var sum time.Duration
for _, e := range entries {
sum += e.Duration
}
return sum
}
+72
View File
@@ -0,0 +1,72 @@
package ticket_test
import (
"testing"
"time"
"task/internal/ticket"
)
func TestAggregateByProject(t *testing.T) {
entries := []ticket.TimeEntry{
{TicketID: "a", ProjectID: "p1", Duration: time.Hour},
{TicketID: "b", ProjectID: "p1", Duration: 30 * time.Minute},
{TicketID: "c", ProjectID: "p2", Duration: 15 * time.Minute},
}
rows := ticket.Aggregate(entries, nil, ticket.GroupByProject)
if len(rows) != 2 {
t.Fatalf("rows=%d want 2", len(rows))
}
if rows[0].Key != "p1" || rows[0].Duration != 90*time.Minute {
t.Fatalf("first row: %+v", rows[0])
}
if ticket.TotalDuration(entries) != 105*time.Minute {
t.Fatalf("total=%v", ticket.TotalDuration(entries))
}
}
func TestAggregateByStatusAndTicket(t *testing.T) {
tickets := map[string]ticket.Ticket{
"a": {ID: "a", Title: "Alpha", Status: ticket.StatusTodo},
"b": {ID: "b", Title: "Beta", Status: ticket.StatusDone},
}
entries := []ticket.TimeEntry{
{TicketID: "a", Duration: time.Hour},
{TicketID: "b", Duration: 20 * time.Minute},
{TicketID: "a", Duration: 10 * time.Minute},
}
byStatus := ticket.Aggregate(entries, tickets, ticket.GroupByStatus)
if len(byStatus) != 2 {
t.Fatalf("status rows=%d", len(byStatus))
}
byTicket := ticket.Aggregate(entries, tickets, ticket.GroupByTicket)
if len(byTicket) != 2 {
t.Fatalf("ticket rows=%d", len(byTicket))
}
if byTicket[0].Label != "Alpha" || byTicket[0].Duration != 70*time.Minute {
t.Fatalf("top ticket: %+v", byTicket[0])
}
}
func TestAggregateByDay(t *testing.T) {
d1 := time.Date(2026, 10, 18, 10, 0, 0, 0, time.Local)
d2 := time.Date(2026, 10, 19, 11, 0, 0, 0, time.Local)
entries := []ticket.TimeEntry{
{Start: d1, Duration: time.Hour},
{Start: d2, Duration: 30 * time.Minute},
{Start: d1, Duration: 15 * time.Minute},
}
rows := ticket.Aggregate(entries, nil, ticket.GroupByDay)
if len(rows) != 2 {
t.Fatalf("day rows=%d", len(rows))
}
// Newest day first.
if rows[0].Key != "2026-10-19" {
t.Fatalf("first day key=%q", rows[0].Key)
}
if rows[1].Duration != 75*time.Minute {
t.Fatalf("older day dur=%v", rows[1].Duration)
}
}
+105
View File
@@ -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)
}
}
+16
View File
@@ -0,0 +1,16 @@
package ticket
import "time"
// TimeStore persists time entries and the optional active timer.
type TimeStore interface {
List() ([]TimeEntry, error)
ListByTicket(ticketID string) ([]TimeEntry, error)
Create(e TimeEntry) (TimeEntry, error)
Update(e TimeEntry) error
Delete(id string) error
SumByTicket(ticketID string) (time.Duration, error)
LoadActiveTimer() (*ActiveTimer, error)
SaveActiveTimer(t *ActiveTimer) error // nil clears
}
+527 -5
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"strings" "strings"
"time"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
@@ -19,6 +20,9 @@ import (
"task/internal/version" "task/internal/version"
) )
// timerTickMsg refreshes the header while a timer is running.
type timerTickMsg time.Time
// editorFinishedMsg is sent after the external $EDITOR exits. // editorFinishedMsg is sent after the external $EDITOR exits.
type editorFinishedMsg struct { type editorFinishedMsg struct {
ticketID string // empty when create==true ticketID string // empty when create==true
@@ -38,12 +42,18 @@ const (
ModeStatus ModeStatus
ModeBoard ModeBoard
ModeConfirmDelete ModeConfirmDelete
ModeTimeManual
ModeTimeEntries
ModeTimeEdit
ModeReport
ModeReportGroup
) )
// Model is the root Bubble Tea model. // Model is the root Bubble Tea model.
type Model struct { type Model struct {
cfg *config.Config cfg *config.Config
store ticket.Store store ticket.Store
timeStore ticket.TimeStore
projects project.Store projects project.Store
exec command.Executor exec command.Executor
@@ -54,6 +64,14 @@ type Model struct {
boardMenu views.BoardMenu boardMenu views.BoardMenu
boardFilter ticket.BoardFilter boardFilter ticket.BoardFilter
input textinput.Model input textinput.Model
report views.ReportView
reportGroupBy ticket.GroupBy
groupByMenu views.GroupByMenu
timeEntries views.TimeEntriesView
timeEntriesTicket string // ticket ID for ModeTimeEntries
editingTimeEntry ticket.TimeEntry
activeTimer *ticket.ActiveTimer
spentByID map[string]time.Duration
width int width int
height int height int
status string status string
@@ -68,7 +86,7 @@ type Model struct {
} }
// New creates the root UI model. // New creates the root UI model.
func New(cfg *config.Config, store ticket.Store, projects project.Store, exec command.Executor) Model { func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor) Model {
styles := DefaultStyles() styles := DefaultStyles()
if exec == nil { if exec == nil {
exec = command.NewHistory() exec = command.NewHistory()
@@ -77,10 +95,13 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, exec co
m := Model{ m := Model{
cfg: cfg, cfg: cfg,
store: store, store: store,
timeStore: timeStore,
projects: projects, projects: projects,
exec: exec, exec: exec,
mode: ModeList, mode: ModeList,
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)), boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
reportGroupBy: ticket.GroupByProject,
spentByID: map[string]time.Duration{},
tickets: views.TicketList{ tickets: views.TicketList{
Styles: views.TicketListStyles{ Styles: views.TicketListStyles{
Card: styles.Card, Card: styles.Card,
@@ -90,21 +111,36 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, exec co
StatusDONE: styles.StatusDone, StatusDONE: styles.StatusDone,
Headline: styles.Headline, Headline: styles.Headline,
}, },
SpentByID: map[string]time.Duration{},
}, },
input: views.NewCreateInput(), input: views.NewCreateInput(),
keys: DefaultKeyMap(), keys: DefaultKeyMap(),
styles: styles, styles: styles,
version: version.BuildVersion, version: version.BuildVersion,
} }
if timeStore != nil {
if t, err := timeStore.LoadActiveTimer(); err == nil {
m.activeTimer = t
}
}
_ = m.reloadTickets() _ = m.reloadTickets()
return m return m
} }
// Init implements tea.Model. // Init implements tea.Model.
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
if m.activeTimer != nil {
return timerTickCmd()
}
return nil return nil
} }
func timerTickCmd() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return timerTickMsg(t)
})
}
// Update implements tea.Model. // Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
@@ -114,12 +150,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.resizeLists() m.resizeLists()
return m, nil return m, nil
case timerTickMsg:
if m.activeTimer != nil {
return m, timerTickCmd()
}
return m, nil
case editorFinishedMsg: case editorFinishedMsg:
return m.handleEditorFinished(msg) return m.handleEditorFinished(msg)
case tea.KeyMsg: case tea.KeyMsg:
switch m.mode { switch m.mode {
case ModeCreate, ModeSearch: case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
return m.updateInput(msg) return m.updateInput(msg)
case ModeProjects: case ModeProjects:
return m.updateProjects(msg) return m.updateProjects(msg)
@@ -129,6 +171,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateBoard(msg) return m.updateBoard(msg)
case ModeConfirmDelete: case ModeConfirmDelete:
return m.updateConfirmDelete(msg) return m.updateConfirmDelete(msg)
case ModeTimeEntries:
return m.updateTimeEntries(msg)
case ModeReport:
return m.updateReport(msg)
case ModeReportGroup:
return m.updateReportGroup(msg)
default: default:
return m.updateList(msg) return m.updateList(msg)
} }
@@ -138,7 +186,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.mode { switch m.mode {
case ModeProjects: case ModeProjects:
m.projectList, cmd = m.projectList.Update(msg) m.projectList, cmd = m.projectList.Update(msg)
case ModeCreate, ModeSearch: case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
m.input, cmd = m.input.Update(msg) m.input, cmd = m.input.Update(msg)
} }
return m, cmd return m, cmd
@@ -173,6 +221,9 @@ func (m *Model) resizeLists() {
m.input.Width = max(10, m.width-4) m.input.Width = max(10, m.width-4)
m.statusMenu.Width = m.width m.statusMenu.Width = m.width
m.boardMenu.Width = m.width m.boardMenu.Width = m.width
m.groupByMenu.Width = m.width
m.report.SetSize(m.width, bodyH)
m.timeEntries.SetSize(m.width, bodyH)
} }
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -276,6 +327,44 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.mode = ModeStatus m.mode = ModeStatus
return m, nil return m, nil
case key.Matches(msg, m.keys.Timer):
m.clearStatus()
cmd, err := m.toggleTimer()
if err != nil {
m.setError(err)
return m, nil
}
return m, cmd
case key.Matches(msg, m.keys.TimeManual):
m.clearStatus()
if _, ok := m.tickets.Selected(); !ok {
m.setError(fmt.Errorf("no ticket selected"))
return m, nil
}
m.mode = ModeTimeManual
m.input = views.NewTimeManualInput()
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Report):
m.clearStatus()
if err := m.openReport(); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeReport
return m, nil
case key.Matches(msg, m.keys.TimeEntries):
m.clearStatus()
if err := m.openTimeEntries(); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeTimeEntries
return m, nil
case key.Matches(msg, m.keys.Down): case key.Matches(msg, m.keys.Down):
m.tickets.MoveDown() m.tickets.MoveDown()
return m, nil return m, nil
@@ -385,9 +474,12 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.creatingProject = false m.creatingProject = false
m.input.Blur() m.input.Blur()
m.clearStatus() m.clearStatus()
if wasProject { switch {
case wasProject:
m.mode = ModeProjects m.mode = ModeProjects
} else { case m.mode == ModeTimeEdit:
m.mode = ModeTimeEntries
default:
m.mode = ModeList m.mode = ModeList
} }
return m, nil return m, nil
@@ -408,6 +500,26 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Enter): case key.Matches(msg, m.keys.Enter):
value := strings.TrimSpace(m.input.Value()) value := strings.TrimSpace(m.input.Value())
if m.mode == ModeTimeEdit {
if err := m.saveEditedTime(value); err != nil {
m.setError(err)
return m, nil
}
m.input.Blur()
m.mode = ModeTimeEntries
m.setStatus("time updated")
return m, nil
}
if m.mode == ModeTimeManual {
if err := m.addManualTime(value); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
m.input.Blur()
m.setStatus("time logged")
return m, nil
}
if m.mode == ModeCreate { if m.mode == ModeCreate {
if value == "" { if value == "" {
m.setError(fmt.Errorf("name required")) m.setError(fmt.Errorf("name required"))
@@ -455,6 +567,135 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m Model) updateTimeEntries(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.timeEntriesTicket = ""
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Down):
m.timeEntries.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.timeEntries.MoveUp()
return m, nil
case key.Matches(msg, m.keys.Delete), key.Matches(msg, m.keys.DeleteForce):
m.clearStatus()
if err := m.deleteSelectedTimeEntry(); err != nil {
m.setError(err)
} else {
m.setStatus("time entry deleted")
}
return m, nil
case key.Matches(msg, m.keys.Edit):
m.clearStatus()
e, ok := m.timeEntries.Selected()
if !ok {
m.setError(fmt.Errorf("no entry selected"))
return m, nil
}
m.editingTimeEntry = e
m.mode = ModeTimeEdit
m.input = views.NewTimeManualInput()
m.input.SetValue(ticket.FormatDurationHuman(e.Duration))
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Undo):
m.clearStatus()
if err := m.undo(); err != nil {
m.setError(err)
return m, nil
}
if err := m.openTimeEntriesFor(m.timeEntriesTicket); err != nil {
m.setError(err)
}
return m, nil
case key.Matches(msg, m.keys.Redo):
m.clearStatus()
if err := m.redo(); err != nil {
m.setError(err)
return m, nil
}
if err := m.openTimeEntriesFor(m.timeEntriesTicket); err != nil {
m.setError(err)
}
return m, nil
}
return m, nil
}
func (m Model) updateReport(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Down):
m.report.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.report.MoveUp()
return m, nil
}
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
switch msg.Runes[0] {
case 'g', 'G':
m.groupByMenu = views.NewGroupByMenu(m.reportGroupBy, m.width, views.StatusMenuStyles{
Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected,
Normal: m.styles.StatusMenuNormal,
})
m.mode = ModeReportGroup
return m, nil
}
}
return m, nil
}
func (m Model) updateReportGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeReport
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Enter):
m.reportGroupBy = m.groupByMenu.Selected()
if err := m.openReport(); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeReport
return m, nil
case key.Matches(msg, m.keys.Down):
m.groupByMenu.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.groupByMenu.MoveUp()
return m, nil
}
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
if m.groupByMenu.JumpToShortcut(msg.Runes[0]) {
return m, nil
}
}
return m, nil
}
func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch { switch {
case key.Matches(msg, m.keys.Escape): case key.Matches(msg, m.keys.Escape):
@@ -730,10 +971,291 @@ func (m *Model) reloadTickets() error {
return err return err
} }
m.allTickets = tickets m.allTickets = tickets
if err := m.reloadSpent(); err != nil {
return err
}
m.applyFilter() m.applyFilter()
return nil return nil
} }
func (m *Model) reloadSpent() error {
m.spentByID = map[string]time.Duration{}
if m.timeStore == nil {
m.tickets.SpentByID = m.spentByID
return nil
}
for _, t := range m.allTickets {
sum, err := m.timeStore.SumByTicket(t.ID)
if err != nil {
return err
}
if sum > 0 {
m.spentByID[t.ID] = sum
}
}
m.tickets.SpentByID = m.spentByID
return nil
}
func (m *Model) toggleTimer() (tea.Cmd, error) {
if m.timeStore == nil {
return nil, fmt.Errorf("time store not configured")
}
sel, ok := m.tickets.Selected()
if !ok {
return nil, fmt.Errorf("no ticket selected")
}
if m.activeTimer == nil {
t := &ticket.ActiveTimer{
TicketID: sel.ID,
ProjectID: sel.ProjectID,
StartedAt: time.Now().UTC(),
}
if err := m.timeStore.SaveActiveTimer(t); err != nil {
return nil, err
}
m.activeTimer = t
m.setStatus("timer started")
return timerTickCmd(), nil
}
// Stop current timer (same or other ticket).
stoppedID := m.activeTimer.TicketID
if err := m.stopActiveTimer(); err != nil {
return nil, err
}
m.setStatus("timer stopped")
// If selection is a different ticket, start a new timer there.
if sel.ID != stoppedID {
t := &ticket.ActiveTimer{
TicketID: sel.ID,
ProjectID: sel.ProjectID,
StartedAt: time.Now().UTC(),
}
if err := m.timeStore.SaveActiveTimer(t); err != nil {
return nil, err
}
m.activeTimer = t
m.setStatus("timer switched")
return timerTickCmd(), nil
}
return nil, nil
}
func (m *Model) stopActiveTimer() error {
if m.activeTimer == nil || m.timeStore == nil {
return nil
}
now := time.Now().UTC()
start := m.activeTimer.StartedAt
dur := now.Sub(start)
if dur < time.Second {
dur = time.Second
}
entry := ticket.TimeEntry{
TicketID: m.activeTimer.TicketID,
ProjectID: m.activeTimer.ProjectID,
Start: start,
End: now,
Duration: dur,
Source: ticket.SourceTimer,
}
if err := m.exec.Do(&command.AddTimeEntry{Store: m.timeStore, Entry: entry}); err != nil {
return err
}
if err := m.timeStore.SaveActiveTimer(nil); err != nil {
return err
}
m.activeTimer = nil
return m.reloadSpent()
}
func (m *Model) addManualTime(raw string) error {
if m.timeStore == nil {
return fmt.Errorf("time store not configured")
}
sel, ok := m.tickets.Selected()
if !ok {
return fmt.Errorf("no ticket selected")
}
dur, err := ticket.ParseDurationFlexible(raw)
if err != nil {
return err
}
end := time.Now().UTC()
start := end.Add(-dur)
entry := ticket.TimeEntry{
TicketID: sel.ID,
ProjectID: sel.ProjectID,
Start: start,
End: end,
Duration: dur,
Source: ticket.SourceManual,
}
if err := m.exec.Do(&command.AddTimeEntry{Store: m.timeStore, Entry: entry}); err != nil {
return err
}
return m.reloadSpent()
}
func (m *Model) openTimeEntries() error {
sel, ok := m.tickets.Selected()
if !ok {
return fmt.Errorf("no ticket selected")
}
return m.openTimeEntriesFor(sel.ID)
}
func (m *Model) openTimeEntriesFor(ticketID string) error {
if m.timeStore == nil {
return fmt.Errorf("time store not configured")
}
if ticketID == "" {
return fmt.Errorf("no ticket selected")
}
entries, err := m.timeStore.ListByTicket(ticketID)
if err != nil {
return err
}
title := ticketID
if t, err := m.store.Get(ticketID); err == nil {
title = strings.TrimSpace(t.Title)
title = strings.TrimLeft(title, "#")
title = strings.TrimSpace(title)
}
headerH, footerH := 1, 2
bodyH := m.height - headerH - footerH
if bodyH < 1 {
bodyH = 1
}
prevCursor := m.timeEntries.Cursor
m.timeEntriesTicket = ticketID
m.timeEntries = views.NewTimeEntriesView(title, entries, m.width, bodyH, views.TimeEntriesStyles{
Title: m.styles.StatusMenuTitle,
Row: m.styles.StatusMenuNormal,
Selected: m.styles.StatusMenuSelected,
Meta: m.styles.HeaderMeta,
Total: m.styles.Headline,
Hint: m.styles.HelpDesc,
Empty: m.styles.HelpDesc,
})
if prevCursor < m.timeEntries.Len() {
m.timeEntries.Cursor = prevCursor
m.timeEntries.SetSize(m.width, bodyH)
}
return nil
}
func (m *Model) deleteSelectedTimeEntry() error {
e, ok := m.timeEntries.Selected()
if !ok {
return fmt.Errorf("no entry selected")
}
if err := m.exec.Do(&command.RemoveTimeEntry{Store: m.timeStore, Snapshot: e}); err != nil {
return err
}
if err := m.reloadSpent(); err != nil {
return err
}
return m.openTimeEntriesFor(m.timeEntriesTicket)
}
func (m *Model) saveEditedTime(raw string) error {
if m.timeStore == nil {
return fmt.Errorf("time store not configured")
}
dur, err := ticket.ParseDurationFlexible(raw)
if err != nil {
return err
}
before := m.editingTimeEntry
after := before
after.Duration = dur
after.End = after.Start.Add(dur)
if err := m.exec.Do(&command.UpdateTimeEntry{
Store: m.timeStore,
Before: before,
After: after,
}); err != nil {
return err
}
if err := m.reloadSpent(); err != nil {
return err
}
return m.openTimeEntriesFor(m.timeEntriesTicket)
}
func (m *Model) openReport() error {
if m.timeStore == nil {
return fmt.Errorf("time store not configured")
}
entries, err := m.timeStore.List()
if err != nil {
return err
}
byID, err := m.allTicketsByID()
if err != nil {
return err
}
rows := ticket.Aggregate(entries, byID, m.reportGroupBy)
total := ticket.TotalDuration(entries)
headerH, footerH := 1, 2
bodyH := m.height - headerH - footerH
if bodyH < 1 {
bodyH = 1
}
m.report = views.NewReportView(rows, total, m.reportGroupBy, m.width, bodyH, views.ReportStyles{
Title: m.styles.StatusMenuTitle,
Group: m.styles.StatusMenuNormal,
Duration: m.styles.HeaderMeta,
Total: m.styles.Headline,
Hint: m.styles.HelpDesc,
Selected: m.styles.StatusMenuSelected,
})
return nil
}
func (m *Model) allTicketsByID() (map[string]ticket.Ticket, error) {
out := map[string]ticket.Ticket{}
projects, err := m.projects.List()
if err != nil {
return nil, err
}
for _, p := range projects {
list, err := m.store.List(p.ID)
if err != nil {
return nil, err
}
for _, t := range list {
out[t.ID] = t
}
}
return out, nil
}
func (m Model) timerHeaderLabel() string {
if m.activeTimer == nil {
return ""
}
title := m.activeTimer.TicketID
if t, err := m.store.Get(m.activeTimer.TicketID); err == nil {
title = strings.TrimSpace(t.Title)
title = strings.TrimLeft(title, "#")
title = strings.TrimSpace(title)
if lipgloss.Width(title) > 24 {
runes := []rune(title)
for len(runes) > 0 && lipgloss.Width(string(runes)) > 23 {
runes = runes[:len(runes)-1]
}
title = string(runes) + "…"
}
}
elapsed := time.Since(m.activeTimer.StartedAt)
return fmt.Sprintf("timer: %s %s", title, ticket.FormatDurationHuman(elapsed))
}
func (m *Model) applyFilter() { func (m *Model) applyFilter() {
filtered := make([]ticket.Ticket, 0, len(m.allTickets)) filtered := make([]ticket.Ticket, 0, len(m.allTickets))
for _, t := range m.allTickets { for _, t := range m.allTickets {
+34
View File
@@ -55,11 +55,45 @@ func (m Model) helpEntries() []helpEntry {
{"y/enter", "confirm"}, {"y/enter", "confirm"},
{"esc", "cancel"}, {"esc", "cancel"},
} }
case ModeTimeManual:
return []helpEntry{
{"enter", "save"},
{"esc", "cancel"},
}
case ModeTimeEntries:
return []helpEntry{
{"j/k", "navigate"},
{"e", "edit"},
{"x", "delete"},
{"u/U", "undo/redo"},
{"esc", "back"},
}
case ModeTimeEdit:
return []helpEntry{
{"enter", "save"},
{"esc", "cancel"},
}
case ModeReport:
return []helpEntry{
{"g", "group"},
{"j/k", "scroll"},
{"esc", "back"},
}
case ModeReportGroup:
return []helpEntry{
{"j/k", "select"},
{"p/s/t/d", "jump"},
{"enter", "apply"},
{"esc", "cancel"},
}
default: default:
return []helpEntry{ return []helpEntry{
{"j/k", "navigate"}, {"j/k", "navigate"},
{"n", "new"}, {"n", "new"},
{"e", "edit"}, {"e", "edit"},
{"t/T", "timer/log"},
{"m", "entries"},
{"r", "report"},
{"u/U", "undo/redo"}, {"u/U", "undo/redo"},
{"/", "search"}, {"/", "search"},
{"p", "project"}, {"p", "project"},
+21 -1
View File
@@ -17,6 +17,10 @@ type KeyMap struct {
DeleteForce key.Binding // X — immediate DeleteForce key.Binding // X — immediate
ConfirmYes key.Binding ConfirmYes key.Binding
Toggle key.Binding // space — toggle multi-select Toggle key.Binding // space — toggle multi-select
Timer key.Binding // t — start/stop timer
TimeManual key.Binding // T — manual duration
TimeEntries key.Binding // m — manage time entries
Report key.Binding // r — time report
Undo key.Binding Undo key.Binding
Redo key.Binding Redo key.Binding
Quit key.Binding Quit key.Binding
@@ -79,6 +83,22 @@ func DefaultKeyMap() KeyMap {
key.WithKeys(" "), key.WithKeys(" "),
key.WithHelp("space", "toggle"), key.WithHelp("space", "toggle"),
), ),
Timer: key.NewBinding(
key.WithKeys("t"),
key.WithHelp("t", "timer"),
),
TimeManual: key.NewBinding(
key.WithKeys("T"),
key.WithHelp("T", "log time"),
),
TimeEntries: key.NewBinding(
key.WithKeys("m"),
key.WithHelp("m", "time entries"),
),
Report: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "report"),
),
Undo: key.NewBinding( Undo: key.NewBinding(
key.WithKeys("u", "ctrl+z"), key.WithKeys("u", "ctrl+z"),
key.WithHelp("u", "undo"), key.WithHelp("u", "undo"),
@@ -104,7 +124,7 @@ func DefaultKeyMap() KeyMap {
// ShortHelp implements help.KeyMap. // ShortHelp implements help.KeyMap.
func (k KeyMap) ShortHelp() []key.Binding { func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit} return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
} }
// FullHelp implements help.KeyMap. // FullHelp implements help.KeyMap.
+25
View File
@@ -16,6 +16,9 @@ func (m Model) renderHeader() string {
count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount())) count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
parts := []string{brand, project, board, count} parts := []string{brand, project, board, count}
if label := m.timerHeaderLabel(); label != "" {
parts = append(parts, m.styles.FilterHint.Render(label))
}
if m.filter != "" { if m.filter != "" {
parts = append(parts, m.styles.FilterHint.Render(fmt.Sprintf("filter: %q", m.filter))) parts = append(parts, m.styles.FilterHint.Render(fmt.Sprintf("filter: %q", m.filter)))
} }
@@ -51,6 +54,28 @@ func (m Model) renderBody(height int) string {
content := prompt + "\n" + m.input.View() content := prompt + "\n" + m.input.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content) return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
case ModeTimeManual:
prompt := m.styles.InputPrompt.Render("Log time:")
content := prompt + "\n" + m.input.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
case ModeTimeEdit:
prompt := m.styles.InputPrompt.Render("Edit duration:")
content := prompt + "\n" + m.input.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
case ModeTimeEntries:
m.timeEntries.SetSize(m.width, height)
return m.timeEntries.View()
case ModeReport:
m.report.SetSize(m.width, height)
return m.report.View()
case ModeReportGroup:
menu := m.groupByMenu.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
case ModeProjects: case ModeProjects:
m.projectList.SetSize(m.width, height) m.projectList.SetSize(m.width, height)
return m.projectList.View() return m.projectList.View()
+9
View File
@@ -12,3 +12,12 @@ func NewCreateInput() textinput.Model {
ti.Width = 60 ti.Width = 60
return ti return ti
} }
// NewTimeManualInput returns a text input for manual duration entry.
func NewTimeManualInput() textinput.Model {
ti := textinput.New()
ti.Placeholder = "Duration (1h30m, 90m, 1.5h)…"
ti.CharLimit = 32
ti.Width = 60
return ti
}
+79
View File
@@ -0,0 +1,79 @@
package views
import (
"fmt"
"strings"
"unicode"
"task/internal/ticket"
)
// GroupByMenu renders a report grouping picker.
type GroupByMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
}
// NewGroupByMenu creates a menu with the given grouping pre-selected.
func NewGroupByMenu(current ticket.GroupBy, width int, styles StatusMenuStyles) GroupByMenu {
return GroupByMenu{
Cursor: current.Index(),
Width: width,
Styles: styles,
}
}
// MoveDown moves the selection down.
func (m *GroupByMenu) MoveDown() {
if m.Cursor < len(ticket.AllGroupBys)-1 {
m.Cursor++
}
}
// MoveUp moves the selection up.
func (m *GroupByMenu) MoveUp() {
if m.Cursor > 0 {
m.Cursor--
}
}
// JumpToShortcut moves the cursor to the option whose shortcut matches r.
func (m *GroupByMenu) JumpToShortcut(r rune) bool {
r = unicode.ToLower(r)
for i, g := range ticket.AllGroupBys {
if g.Shortcut() == r {
m.Cursor = i
return true
}
}
return false
}
// Selected returns the currently highlighted grouping.
func (m GroupByMenu) Selected() ticket.GroupBy {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllGroupBys) {
return ticket.GroupByProject
}
return ticket.AllGroupBys[m.Cursor]
}
// View renders the grouping menu.
func (m GroupByMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("Group report by"))
for i, g := range ticket.AllGroupBys {
entry := fmt.Sprintf("%c %s", g.Shortcut(), g.Label())
if i == m.Cursor {
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
} else {
lines = append(lines, m.Styles.Normal.Render(" "+entry))
}
}
body := strings.Join(lines, "\n")
w := m.Width
if w < 20 {
w = 20
}
return m.Styles.Box.Width(min(40, w-2)).Render(body)
}
+153
View File
@@ -0,0 +1,153 @@
package views
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// ReportStyles holds styles for the report dialog.
type ReportStyles struct {
Title lipgloss.Style
Group lipgloss.Style
Duration lipgloss.Style
Total lipgloss.Style
Hint lipgloss.Style
Selected lipgloss.Style
}
// ReportView renders aggregated time entries.
type ReportView struct {
Rows []ticket.GroupRow
Total time.Duration
GroupBy ticket.GroupBy
Offset int
Cursor int
Width int
Height int
Styles ReportStyles
}
// NewReportView builds a report view from aggregated rows.
func NewReportView(rows []ticket.GroupRow, total time.Duration, groupBy ticket.GroupBy, width, height int, styles ReportStyles) ReportView {
return ReportView{
Rows: rows,
Total: total,
GroupBy: groupBy,
Width: width,
Height: height,
Styles: styles,
}
}
// SetSize updates viewport size.
func (r *ReportView) SetSize(width, height int) {
r.Width = width
r.Height = height
r.ensureVisible()
}
// MoveDown moves selection down.
func (r *ReportView) MoveDown() {
if r.Cursor < len(r.Rows)-1 {
r.Cursor++
r.ensureVisible()
}
}
// MoveUp moves selection up.
func (r *ReportView) MoveUp() {
if r.Cursor > 0 {
r.Cursor--
r.ensureVisible()
}
}
// View renders the report.
func (r ReportView) View() string {
title := r.Styles.Title.Render(fmt.Sprintf("Time report · by %s", r.GroupBy.Label()))
hint := r.Styles.Hint.Render("g group · j/k scroll · esc back")
innerW := max(20, r.Width-2)
var lines []string
lines = append(lines, title, "")
visibleH := r.Height - 4 // title, blank, total, hint
if visibleH < 1 {
visibleH = 1
}
if len(r.Rows) == 0 {
lines = append(lines, r.Styles.Hint.Render("No time entries yet"))
} else {
end := r.Offset + visibleH
if end > len(r.Rows) {
end = len(r.Rows)
}
for i := r.Offset; i < end; i++ {
row := r.Rows[i]
label := truncateOneLine(row.Label, max(8, innerW-14))
dur := ticket.FormatDurationHuman(row.Duration)
dots := dotsPad(label, dur, innerW)
line := label + dots + dur
if i == r.Cursor {
line = r.Styles.Selected.Render(line)
} else {
line = r.Styles.Group.Render(label) + dots + r.Styles.Duration.Render(dur)
}
lines = append(lines, line)
}
}
for len(lines) < visibleH+2 {
lines = append(lines, "")
}
totalLabel := "Total"
totalDur := ticket.FormatDurationHuman(r.Total)
totalLine := totalLabel + dotsPad(totalLabel, totalDur, innerW) + totalDur
lines = append(lines, r.Styles.Total.Render(totalLine), hint)
body := strings.Join(lines, "\n")
return lipgloss.NewStyle().Width(r.Width).Height(r.Height).Render(body)
}
func dotsPad(left, right string, width int) string {
used := lipgloss.Width(left) + lipgloss.Width(right)
n := width - used
if n < 2 {
return " "
}
return " " + strings.Repeat("·", n-2) + " "
}
func (r *ReportView) ensureVisible() {
if len(r.Rows) == 0 {
r.Offset = 0
r.Cursor = 0
return
}
if r.Cursor < 0 {
r.Cursor = 0
}
if r.Cursor >= len(r.Rows) {
r.Cursor = len(r.Rows) - 1
}
visibleH := r.Height - 4
if visibleH < 1 {
visibleH = 1
}
if r.Cursor < r.Offset {
r.Offset = r.Cursor
}
if r.Cursor >= r.Offset+visibleH {
r.Offset = r.Cursor - visibleH + 1
}
if r.Offset < 0 {
r.Offset = 0
}
}
+15 -6
View File
@@ -3,6 +3,7 @@ package views
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -31,6 +32,7 @@ type TicketList struct {
Width int Width int
Height int Height int
Styles TicketListStyles Styles TicketListStyles
SpentByID map[string]time.Duration // optional; shown on selected card
} }
// SetSize updates the viewport dimensions. // SetSize updates the viewport dimensions.
@@ -124,12 +126,16 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
var content string var content string
if selected { if selected {
// Expanded: headline + glamour-rendered description (fixed dark style). // Expanded: headline + optional spent + glamour description.
content = headerLine
if spent, ok := l.SpentByID[t.ID]; ok && spent > 0 {
content += "\n" + lipgloss.NewStyle().Faint(true).Render(
"spent: "+ticket.FormatDurationHuman(spent),
)
}
desc := strings.TrimSpace(t.Description) desc := strings.TrimSpace(t.Description)
if desc != "" { if desc != "" {
content = headerLine + "\n" + RenderMarkdown(desc, innerWidth) content += "\n" + RenderMarkdown(desc, innerWidth)
} else {
content = headerLine
} }
} else { } else {
content = headerLine content = headerLine
@@ -190,8 +196,11 @@ func (l *TicketList) estimateHeight(index int) int {
if index < 0 || index >= len(l.Items) { if index < 0 || index >= len(l.Items) {
return CollapsedCardHeight return CollapsedCardHeight
} }
if index == l.Cursor && strings.TrimSpace(l.Items[index].Description) != "" { if index == l.Cursor {
return lipgloss.Height(l.renderCard(l.Items[index], true)) t := l.Items[index]
if strings.TrimSpace(t.Description) != "" || (l.SpentByID != nil && l.SpentByID[t.ID] > 0) {
return lipgloss.Height(l.renderCard(t, true))
}
} }
return CollapsedCardHeight return CollapsedCardHeight
} }
+181
View File
@@ -0,0 +1,181 @@
package views
import (
"fmt"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// TimeEntriesStyles holds styles for the time-entry list.
type TimeEntriesStyles struct {
Title lipgloss.Style
Row lipgloss.Style
Selected lipgloss.Style
Meta lipgloss.Style
Total lipgloss.Style
Hint lipgloss.Style
Empty lipgloss.Style
}
// TimeEntriesView lists time entries for one ticket.
type TimeEntriesView struct {
TicketTitle string
Entries []ticket.TimeEntry
Total time.Duration
Cursor int
Offset int
Width int
Height int
Styles TimeEntriesStyles
}
// NewTimeEntriesView builds a sorted (newest first) entry list.
func NewTimeEntriesView(title string, entries []ticket.TimeEntry, width, height int, styles TimeEntriesStyles) TimeEntriesView {
cp := append([]ticket.TimeEntry(nil), entries...)
sort.Slice(cp, func(i, j int) bool {
return cp[i].Start.After(cp[j].Start)
})
var total time.Duration
for _, e := range cp {
total += e.Duration
}
v := TimeEntriesView{
TicketTitle: title,
Entries: cp,
Total: total,
Width: width,
Height: height,
Styles: styles,
}
v.ensureVisible()
return v
}
// SetSize updates viewport size.
func (v *TimeEntriesView) SetSize(width, height int) {
v.Width = width
v.Height = height
v.ensureVisible()
}
// Selected returns the highlighted entry.
func (v TimeEntriesView) Selected() (ticket.TimeEntry, bool) {
if len(v.Entries) == 0 || v.Cursor < 0 || v.Cursor >= len(v.Entries) {
return ticket.TimeEntry{}, false
}
return v.Entries[v.Cursor], true
}
// Len returns the number of entries.
func (v TimeEntriesView) Len() int { return len(v.Entries) }
// MoveDown moves selection down.
func (v *TimeEntriesView) MoveDown() {
if v.Cursor < len(v.Entries)-1 {
v.Cursor++
v.ensureVisible()
}
}
// MoveUp moves selection up.
func (v *TimeEntriesView) MoveUp() {
if v.Cursor > 0 {
v.Cursor--
v.ensureVisible()
}
}
// View renders the list.
func (v TimeEntriesView) View() string {
title := strings.TrimSpace(v.TicketTitle)
if title == "" {
title = "ticket"
}
head := v.Styles.Title.Render(fmt.Sprintf("Time entries · %s", truncateOneLine(title, max(8, v.Width-20))))
hint := v.Styles.Hint.Render("j/k navigate · e edit · x delete · esc back")
innerW := max(20, v.Width-2)
var lines []string
lines = append(lines, head, "")
visibleH := v.Height - 4
if visibleH < 1 {
visibleH = 1
}
if len(v.Entries) == 0 {
lines = append(lines, v.Styles.Empty.Render("No time entries for this ticket"))
} else {
end := v.Offset + visibleH
if end > len(v.Entries) {
end = len(v.Entries)
}
for i := v.Offset; i < end; i++ {
line := formatEntryLine(v.Entries[i], innerW)
if i == v.Cursor {
line = v.Styles.Selected.Render(line)
} else {
line = v.Styles.Row.Render(line)
}
lines = append(lines, line)
}
}
for len(lines) < visibleH+2 {
lines = append(lines, "")
}
totalLabel := "Total"
totalDur := ticket.FormatDurationHuman(v.Total)
totalLine := totalLabel + dotsPad(totalLabel, totalDur, innerW) + totalDur
lines = append(lines, v.Styles.Total.Render(totalLine), hint)
body := strings.Join(lines, "\n")
return lipgloss.NewStyle().Width(v.Width).Height(v.Height).Render(body)
}
func formatEntryLine(e ticket.TimeEntry, width int) string {
when := e.Start.Local().Format("2006-01-02 15:04")
if e.Start.IsZero() {
when = "—"
}
dur := ticket.FormatDurationHuman(e.Duration)
src := e.Source
if src == "" {
src = "?"
}
left := when + " · " + dur + " · " + src
return truncateOneLine(left, width)
}
func (v *TimeEntriesView) ensureVisible() {
if len(v.Entries) == 0 {
v.Offset = 0
v.Cursor = 0
return
}
if v.Cursor < 0 {
v.Cursor = 0
}
if v.Cursor >= len(v.Entries) {
v.Cursor = len(v.Entries) - 1
}
visibleH := v.Height - 4
if visibleH < 1 {
visibleH = 1
}
if v.Cursor < v.Offset {
v.Offset = v.Cursor
}
if v.Cursor >= v.Offset+visibleH {
v.Offset = v.Cursor - visibleH + 1
}
if v.Offset < 0 {
v.Offset = 0
}
}