From bfc1cbd4d9bfd5d06ed9b3518ed5f2d1a74a84d2 Mon Sep 17 00:00:00 2001 From: Skruecken Date: Mon, 19 Oct 2026 11:05:10 +0200 Subject: [PATCH] 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 --- cmd/task-master/main.go | 5 +- internal/command/executor.go | 25 +- internal/command/persist.go | 71 +++- internal/command/persist_test.go | 6 +- internal/command/time_cmds.go | 81 ++++ internal/command/time_cmds_test.go | 75 ++++ internal/ticket/duration.go | 59 +++ internal/ticket/duration_test.go | 58 +++ internal/ticket/jsontimestore.go | 282 ++++++++++++ internal/ticket/jsontimestore_test.go | 71 ++++ internal/ticket/report.go | 157 +++++++ internal/ticket/report_test.go | 72 ++++ internal/ticket/time.go | 105 +++++ internal/ticket/timestore.go | 16 + internal/ui/app.go | 588 ++++++++++++++++++++++++-- internal/ui/helpbar.go | 34 ++ internal/ui/keys.go | 58 ++- internal/ui/layout.go | 25 ++ internal/ui/views/create.go | 9 + internal/ui/views/groupby.go | 79 ++++ internal/ui/views/report.go | 153 +++++++ internal/ui/views/tickets.go | 33 +- internal/ui/views/timeentries.go | 181 ++++++++ 23 files changed, 2149 insertions(+), 94 deletions(-) create mode 100644 internal/command/time_cmds.go create mode 100644 internal/command/time_cmds_test.go create mode 100644 internal/ticket/duration.go create mode 100644 internal/ticket/duration_test.go create mode 100644 internal/ticket/jsontimestore.go create mode 100644 internal/ticket/jsontimestore_test.go create mode 100644 internal/ticket/report.go create mode 100644 internal/ticket/report_test.go create mode 100644 internal/ticket/time.go create mode 100644 internal/ticket/timestore.go create mode 100644 internal/ui/views/groupby.go create mode 100644 internal/ui/views/report.go create mode 100644 internal/ui/views/timeentries.go diff --git a/cmd/task-master/main.go b/cmd/task-master/main.go index e7c054d..2da5266 100644 --- a/cmd/task-master/main.go +++ b/cmd/task-master/main.go @@ -43,16 +43,17 @@ func run() error { } store := ticket.NewJSONStore(cfg.DataDir) + timeStore := ticket.NewJSONTimeStore(cfg.DataDir) projects := project.NewDirStore(cfg.DataDir) if err := projects.Ensure(cfg.ActiveProject); err != nil { 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 { 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()) if _, err := prog.Run(); err != nil { return fmt.Errorf("tui: %w", err) diff --git a/internal/command/executor.go b/internal/command/executor.go index c4836ce..863ae6c 100644 --- a/internal/command/executor.go +++ b/internal/command/executor.go @@ -21,12 +21,13 @@ type Executor interface { // History is an undo/redo stack for Command values, optionally persisted to disk. type History struct { - mu sync.Mutex - undoS []Command - redoS []Command - path string - store ticket.Store - max int + mu sync.Mutex + undoS []Command + redoS []Command + path string + store ticket.Store + timeStore ticket.TimeStore + max int } // NewHistory creates an empty in-memory command history (cap MaxHistory). @@ -36,13 +37,15 @@ func NewHistory() *History { // OpenHistory loads a persisted history from path (DataDir/history.json) or // 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{ - path: path, - store: store, - max: MaxHistory, + path: path, + store: store, + timeStore: timeStore, + max: MaxHistory, } - undo, redo, err := loadHistoryFile(path, store) + undo, redo, err := loadHistoryFile(path, store, timeStore) if err != nil { return nil, err } diff --git a/internal/command/persist.go b/internal/command/persist.go index b4cb67c..ea2aa25 100644 --- a/internal/command/persist.go +++ b/internal/command/persist.go @@ -21,17 +21,23 @@ type persistedFile struct { } type persistedCmd struct { - Type string `json:"type"` - Ticket *ticket.Ticket `json:"ticket,omitempty"` - Snapshot *ticket.Ticket `json:"snapshot,omitempty"` - Before *ticket.Ticket `json:"before,omitempty"` - After *ticket.Ticket `json:"after,omitempty"` + Type string `json:"type"` + Ticket *ticket.Ticket `json:"ticket,omitempty"` + Snapshot *ticket.Ticket `json:"snapshot,omitempty"` + Before *ticket.Ticket `json:"before,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 ( - typeCreate = "create" - typeDelete = "delete" - typeUpdate = "update" + typeCreate = "create" + typeDelete = "delete" + typeUpdate = "update" + typeAddTime = "add_time" + typeRemoveTime = "remove_time" + typeUpdateTime = "update_time" ) // HistoryPath returns DataDir/history.json. @@ -54,12 +60,25 @@ func encodeCmd(cmd Command) (persistedCmd, error) { case *UpdateTicket: b, a := c.Before, c.After 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: 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 { case typeCreate: 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 &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: return nil, fmt.Errorf("unknown command type %q", rec.Type) } @@ -93,10 +136,10 @@ func encodeStack(cmds []Command) ([]persistedCmd, error) { 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)) for _, rec := range recs { - cmd, err := decodeCmd(rec, store) + cmd, err := decodeCmd(rec, store, timeStore) if err != nil { return nil, err } @@ -154,7 +197,7 @@ func (h *History) persistLocked() error { 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) if err != nil { 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 { 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 { return nil, nil, err } - redo, err = decodeStack(file.Redo, store) + redo, err = decodeStack(file.Redo, store, timeStore) if err != nil { return nil, nil, err } diff --git a/internal/command/persist_test.go b/internal/command/persist_test.go index 5e2b383..be2ef74 100644 --- a/internal/command/persist_test.go +++ b/internal/command/persist_test.go @@ -14,7 +14,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) { store := ticket.NewJSONStore(dir) path := command.HistoryPath(dir) - h1, err := command.OpenHistory(path, store) + h1, err := command.OpenHistory(path, store, nil) if err != nil { t.Fatal(err) } @@ -30,7 +30,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) { t.Fatalf("history file missing: %v", err) } - h2, err := command.OpenHistory(path, store) + h2, err := command.OpenHistory(path, store, nil) if err != nil { t.Fatal(err) } @@ -49,7 +49,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) { func TestHistoryCapsAtMax(t *testing.T) { dir := t.TempDir() 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 { t.Fatal(err) } diff --git a/internal/command/time_cmds.go b/internal/command/time_cmds.go new file mode 100644 index 0000000..5fbc766 --- /dev/null +++ b/internal/command/time_cmds.go @@ -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) +} diff --git a/internal/command/time_cmds_test.go b/internal/command/time_cmds_test.go new file mode 100644 index 0000000..a762e9f --- /dev/null +++ b/internal/command/time_cmds_test.go @@ -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) + } +} diff --git a/internal/ticket/duration.go b/internal/ticket/duration.go new file mode 100644 index 0000000..20309cd --- /dev/null +++ b/internal/ticket/duration.go @@ -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) +} diff --git a/internal/ticket/duration_test.go b/internal/ticket/duration_test.go new file mode 100644 index 0000000..922ce6b --- /dev/null +++ b/internal/ticket/duration_test.go @@ -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) + } + } +} diff --git a/internal/ticket/jsontimestore.go b/internal/ticket/jsontimestore.go new file mode 100644 index 0000000..b7522ab --- /dev/null +++ b/internal/ticket/jsontimestore.go @@ -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 +} diff --git a/internal/ticket/jsontimestore_test.go b/internal/ticket/jsontimestore_test.go new file mode 100644 index 0000000..04ed661 --- /dev/null +++ b/internal/ticket/jsontimestore_test.go @@ -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")) + } +} diff --git a/internal/ticket/report.go b/internal/ticket/report.go new file mode 100644 index 0000000..dfb18a0 --- /dev/null +++ b/internal/ticket/report.go @@ -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 +} diff --git a/internal/ticket/report_test.go b/internal/ticket/report_test.go new file mode 100644 index 0000000..c71a042 --- /dev/null +++ b/internal/ticket/report_test.go @@ -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) + } +} diff --git a/internal/ticket/time.go b/internal/ticket/time.go new file mode 100644 index 0000000..1d52d3d --- /dev/null +++ b/internal/ticket/time.go @@ -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) + } +} diff --git a/internal/ticket/timestore.go b/internal/ticket/timestore.go new file mode 100644 index 0000000..64e8789 --- /dev/null +++ b/internal/ticket/timestore.go @@ -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 +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 2e978ff..6ce5e28 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" @@ -19,6 +20,9 @@ import ( "task/internal/version" ) +// timerTickMsg refreshes the header while a timer is running. +type timerTickMsg time.Time + // editorFinishedMsg is sent after the external $EDITOR exits. type editorFinishedMsg struct { ticketID string // empty when create==true @@ -38,49 +42,66 @@ const ( ModeStatus ModeBoard ModeConfirmDelete + ModeTimeManual + ModeTimeEntries + ModeTimeEdit + ModeReport + ModeReportGroup ) // Model is the root Bubble Tea model. type Model struct { - cfg *config.Config - store ticket.Store - projects project.Store - exec command.Executor + cfg *config.Config + store ticket.Store + timeStore ticket.TimeStore + projects project.Store + exec command.Executor - mode Mode - tickets views.TicketList - projectList list.Model - statusMenu views.StatusMenu - boardMenu views.BoardMenu - boardFilter ticket.BoardFilter - input textinput.Model - width int - height int - status string - statusIsError bool - filter string - keys KeyMap - styles Styles - version string - allTickets []ticket.Ticket - creatingProject bool - pendingDeleteTitle string + mode Mode + tickets views.TicketList + projectList list.Model + statusMenu views.StatusMenu + boardMenu views.BoardMenu + boardFilter ticket.BoardFilter + 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 + height int + status string + statusIsError bool + filter string + keys KeyMap + styles Styles + version string + allTickets []ticket.Ticket + creatingProject bool + pendingDeleteTitle string } // 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() if exec == nil { exec = command.NewHistory() } m := Model{ - cfg: cfg, - store: store, - projects: projects, - exec: exec, - mode: ModeList, - boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)), + cfg: cfg, + store: store, + timeStore: timeStore, + projects: projects, + exec: exec, + mode: ModeList, + boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)), + reportGroupBy: ticket.GroupByProject, + spentByID: map[string]time.Duration{}, tickets: views.TicketList{ Styles: views.TicketListStyles{ Card: styles.Card, @@ -90,21 +111,36 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, exec co StatusDONE: styles.StatusDone, Headline: styles.Headline, }, + SpentByID: map[string]time.Duration{}, }, input: views.NewCreateInput(), keys: DefaultKeyMap(), styles: styles, version: version.BuildVersion, } + if timeStore != nil { + if t, err := timeStore.LoadActiveTimer(); err == nil { + m.activeTimer = t + } + } _ = m.reloadTickets() return m } // Init implements tea.Model. func (m Model) Init() tea.Cmd { + if m.activeTimer != nil { + return timerTickCmd() + } return nil } +func timerTickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return timerTickMsg(t) + }) +} + // Update implements tea.Model. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -114,12 +150,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resizeLists() return m, nil + case timerTickMsg: + if m.activeTimer != nil { + return m, timerTickCmd() + } + return m, nil + case editorFinishedMsg: return m.handleEditorFinished(msg) case tea.KeyMsg: switch m.mode { - case ModeCreate, ModeSearch: + case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit: return m.updateInput(msg) case ModeProjects: return m.updateProjects(msg) @@ -129,6 +171,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateBoard(msg) case ModeConfirmDelete: return m.updateConfirmDelete(msg) + case ModeTimeEntries: + return m.updateTimeEntries(msg) + case ModeReport: + return m.updateReport(msg) + case ModeReportGroup: + return m.updateReportGroup(msg) default: return m.updateList(msg) } @@ -138,7 +186,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.mode { case ModeProjects: m.projectList, cmd = m.projectList.Update(msg) - case ModeCreate, ModeSearch: + case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit: m.input, cmd = m.input.Update(msg) } return m, cmd @@ -173,6 +221,9 @@ func (m *Model) resizeLists() { m.input.Width = max(10, m.width-4) m.statusMenu.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) { @@ -276,6 +327,44 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = ModeStatus 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): m.tickets.MoveDown() return m, nil @@ -385,9 +474,12 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.creatingProject = false m.input.Blur() m.clearStatus() - if wasProject { + switch { + case wasProject: m.mode = ModeProjects - } else { + case m.mode == ModeTimeEdit: + m.mode = ModeTimeEntries + default: m.mode = ModeList } 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): 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 value == "" { m.setError(fmt.Errorf("name required")) @@ -455,6 +567,135 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.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) { switch { case key.Matches(msg, m.keys.Escape): @@ -730,10 +971,291 @@ func (m *Model) reloadTickets() error { return err } m.allTickets = tickets + if err := m.reloadSpent(); err != nil { + return err + } m.applyFilter() 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() { filtered := make([]ticket.Ticket, 0, len(m.allTickets)) for _, t := range m.allTickets { diff --git a/internal/ui/helpbar.go b/internal/ui/helpbar.go index 4ed3932..86a2b2e 100644 --- a/internal/ui/helpbar.go +++ b/internal/ui/helpbar.go @@ -55,11 +55,45 @@ func (m Model) helpEntries() []helpEntry { {"y/enter", "confirm"}, {"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: return []helpEntry{ {"j/k", "navigate"}, {"n", "new"}, {"e", "edit"}, + {"t/T", "timer/log"}, + {"m", "entries"}, + {"r", "report"}, {"u/U", "undo/redo"}, {"/", "search"}, {"p", "project"}, diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 78aedff..693d3bc 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -4,24 +4,28 @@ import "github.com/charmbracelet/bubbles/key" // KeyMap defines vim-style bindings for the TUI. type KeyMap struct { - Up key.Binding - Down key.Binding - New key.Binding - Search key.Binding - Project key.Binding - Board key.Binding - Status key.Binding - Edit key.Binding - EditInEditor key.Binding // ctrl+e — open $EDITOR while creating - Delete key.Binding // x — confirm first - DeleteForce key.Binding // X — immediate - ConfirmYes key.Binding - Toggle key.Binding // space — toggle multi-select - Undo key.Binding - Redo key.Binding - Quit key.Binding - Enter key.Binding - Escape key.Binding + Up key.Binding + Down key.Binding + New key.Binding + Search key.Binding + Project key.Binding + Board key.Binding + Status key.Binding + Edit key.Binding + EditInEditor key.Binding // ctrl+e — open $EDITOR while creating + Delete key.Binding // x — confirm first + DeleteForce key.Binding // X — immediate + ConfirmYes key.Binding + 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 + Redo key.Binding + Quit key.Binding + Enter key.Binding + Escape key.Binding } // DefaultKeyMap returns the default key bindings. @@ -79,6 +83,22 @@ func DefaultKeyMap() KeyMap { key.WithKeys(" "), 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( key.WithKeys("u", "ctrl+z"), key.WithHelp("u", "undo"), @@ -104,7 +124,7 @@ func DefaultKeyMap() KeyMap { // ShortHelp implements help.KeyMap. 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. diff --git a/internal/ui/layout.go b/internal/ui/layout.go index a53299d..76a67f7 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -16,6 +16,9 @@ func (m Model) renderHeader() string { count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount())) parts := []string{brand, project, board, count} + if label := m.timerHeaderLabel(); label != "" { + parts = append(parts, m.styles.FilterHint.Render(label)) + } if 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() 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: m.projectList.SetSize(m.width, height) return m.projectList.View() diff --git a/internal/ui/views/create.go b/internal/ui/views/create.go index c46c936..4e65d68 100644 --- a/internal/ui/views/create.go +++ b/internal/ui/views/create.go @@ -12,3 +12,12 @@ func NewCreateInput() textinput.Model { ti.Width = 60 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 +} diff --git a/internal/ui/views/groupby.go b/internal/ui/views/groupby.go new file mode 100644 index 0000000..2a536a0 --- /dev/null +++ b/internal/ui/views/groupby.go @@ -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) +} diff --git a/internal/ui/views/report.go b/internal/ui/views/report.go new file mode 100644 index 0000000..dcebd4c --- /dev/null +++ b/internal/ui/views/report.go @@ -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 + } +} diff --git a/internal/ui/views/tickets.go b/internal/ui/views/tickets.go index c3e48e5..420542d 100644 --- a/internal/ui/views/tickets.go +++ b/internal/ui/views/tickets.go @@ -3,6 +3,7 @@ package views import ( "fmt" "strings" + "time" "github.com/charmbracelet/lipgloss" @@ -25,12 +26,13 @@ type TicketListStyles struct { // TicketList is a custom viewport list of ticket cards. type TicketList struct { - Items []ticket.Ticket - Cursor int - Offset int - Width int - Height int - Styles TicketListStyles + Items []ticket.Ticket + Cursor int + Offset int + Width int + Height int + Styles TicketListStyles + SpentByID map[string]time.Duration // optional; shown on selected card } // SetSize updates the viewport dimensions. @@ -124,12 +126,16 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string { var content string 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) if desc != "" { - content = headerLine + "\n" + RenderMarkdown(desc, innerWidth) - } else { - content = headerLine + content += "\n" + RenderMarkdown(desc, innerWidth) } } else { content = headerLine @@ -190,8 +196,11 @@ func (l *TicketList) estimateHeight(index int) int { if index < 0 || index >= len(l.Items) { return CollapsedCardHeight } - if index == l.Cursor && strings.TrimSpace(l.Items[index].Description) != "" { - return lipgloss.Height(l.renderCard(l.Items[index], true)) + if index == l.Cursor { + 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 } diff --git a/internal/ui/views/timeentries.go b/internal/ui/views/timeentries.go new file mode 100644 index 0000000..d5d3a90 --- /dev/null +++ b/internal/ui/views/timeentries.go @@ -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 + } +}