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)
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)
+14 -11
View File
@@ -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
}
+57 -14
View File
@@ -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
}
+3 -3
View File
@@ -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)
}
+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
}
+555 -33
View File
@@ -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 {
+34
View File
@@ -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"},
+39 -19
View File
@@ -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.
+25
View File
@@ -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()
+9
View File
@@ -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
}
+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
}
}
+21 -12
View File
@@ -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
}
+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
}
}