Compare commits
10
Commits
6527d91b4f
...
81f2902ff1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81f2902ff1 | ||
|
|
f08084d5e1 | ||
|
|
7666715f6c | ||
|
|
c5920ffd54 | ||
|
|
f3a592f3ea | ||
|
|
2dcab9e4d8 | ||
|
|
21a0d2ad51 | ||
|
|
bfc1cbd4d9 | ||
|
|
41feba11de | ||
|
|
aa8f89b43a |
@@ -1,4 +1,8 @@
|
|||||||
/bin/
|
/bin/
|
||||||
|
.dev-home/
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
demo.gif
|
||||||
|
demo.mp4
|
||||||
|
demo.webm
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
BINARY := task-master
|
BINARY := tm
|
||||||
CMD := ./cmd/task-master
|
CMD := ./cmd/task-master
|
||||||
VERSION_PKG := task/internal/version
|
VERSION_PKG := task/internal/version
|
||||||
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
|
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
|
||||||
|
|
||||||
.PHONY: build run version
|
# Default: Go workspace bin (e.g. ~/go/bin). Override with: make install PREFIX=...
|
||||||
|
GOPATH ?= $(shell go env GOPATH)
|
||||||
|
PREFIX ?= $(GOPATH)
|
||||||
|
INSTALL_BIN := $(PREFIX)/bin
|
||||||
|
DEV_HOME := $(CURDIR)/.dev-home
|
||||||
|
|
||||||
|
.PHONY: build run run-dev install uninstall test version demo
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
|
||||||
@@ -12,5 +18,25 @@ build:
|
|||||||
run: build
|
run: build
|
||||||
./bin/$(BINARY)
|
./bin/$(BINARY)
|
||||||
|
|
||||||
|
# Sandbox: isolated config/data under ./.dev-home (does not touch ~/.config)
|
||||||
|
run-dev: build
|
||||||
|
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
|
||||||
|
|
||||||
|
install: build
|
||||||
|
install -d $(INSTALL_BIN)
|
||||||
|
install -m 755 bin/$(BINARY) $(INSTALL_BIN)/$(BINARY)
|
||||||
|
@echo "installed $(INSTALL_BIN)/$(BINARY)"
|
||||||
|
|
||||||
|
uninstall:
|
||||||
|
rm -f $(INSTALL_BIN)/$(BINARY)
|
||||||
|
@echo "removed $(INSTALL_BIN)/$(BINARY)"
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
version: build
|
version: build
|
||||||
./bin/$(BINARY) version
|
./bin/$(BINARY) version
|
||||||
|
|
||||||
|
# Record a full feature walkthrough (requires vhs: https://github.com/charmbracelet/vhs)
|
||||||
|
demo:
|
||||||
|
vhs demo.tape
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func main() {
|
|||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "version", "--version", "-v":
|
case "version", "--version", "-v":
|
||||||
fmt.Printf("%s %s\n", config.AppName, version.BuildVersion)
|
fmt.Printf("%s %s\n", config.CliName, version.BuildVersion)
|
||||||
return
|
return
|
||||||
case "help", "--help", "-h":
|
case "help", "--help", "-h":
|
||||||
printHelp()
|
printHelp()
|
||||||
@@ -43,16 +43,17 @@ func run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
store := ticket.NewJSONStore(cfg.DataDir)
|
store := ticket.NewJSONStore(cfg.DataDir)
|
||||||
|
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
|
||||||
projects := project.NewDirStore(cfg.DataDir)
|
projects := project.NewDirStore(cfg.DataDir)
|
||||||
if err := projects.Ensure(cfg.ActiveProject); err != nil {
|
if err := projects.Ensure(cfg.ActiveProject); err != nil {
|
||||||
return fmt.Errorf("ensure project: %w", err)
|
return fmt.Errorf("ensure project: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store)
|
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("history: %w", err)
|
return fmt.Errorf("history: %w", err)
|
||||||
}
|
}
|
||||||
model := ui.New(cfg, store, projects, history)
|
model := ui.New(cfg, store, projects, timeStore, history)
|
||||||
prog := tea.NewProgram(model, tea.WithAltScreen())
|
prog := tea.NewProgram(model, tea.WithAltScreen())
|
||||||
if _, err := prog.Run(); err != nil {
|
if _, err := prog.Run(); err != nil {
|
||||||
return fmt.Errorf("tui: %w", err)
|
return fmt.Errorf("tui: %w", err)
|
||||||
@@ -68,5 +69,9 @@ Usage:
|
|||||||
%s version Print build version
|
%s version Print build version
|
||||||
%s help Show this help
|
%s help Show this help
|
||||||
|
|
||||||
`, config.AppName, config.AppName, config.AppName, config.AppName)
|
Env:
|
||||||
|
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
|
||||||
|
TASK_MASTER_DATA_DIR Override data directory only
|
||||||
|
|
||||||
|
`, config.CliName, config.CliName, config.CliName, config.CliName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# task-master feature demo
|
||||||
|
# Run from repo root: make demo (or: vhs demo.tape)
|
||||||
|
|
||||||
|
Output demo.gif
|
||||||
|
Output demo.mp4
|
||||||
|
|
||||||
|
Require go
|
||||||
|
Require vim
|
||||||
|
|
||||||
|
Set Shell "bash"
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 1280
|
||||||
|
Set Height 820
|
||||||
|
Set Theme "Catppuccin Mocha"
|
||||||
|
Set TypingSpeed 35ms
|
||||||
|
Set PlaybackSpeed 1.0
|
||||||
|
Set Padding 12
|
||||||
|
Set Framerate 30
|
||||||
|
|
||||||
|
# Isolated config/data so the demo never touches your real ~/.config
|
||||||
|
Env TASK_MASTER_HOME "/tmp/task-master-vhs"
|
||||||
|
Env TERM "xterm-256color"
|
||||||
|
Env EDITOR "vim"
|
||||||
|
|
||||||
|
# --- setup (hidden) ----------------------------------------------------------
|
||||||
|
Hide
|
||||||
|
Type "rm -rf /tmp/task-master-vhs && mkdir -p /tmp/task-master-vhs"
|
||||||
|
Enter
|
||||||
|
Sleep 300ms
|
||||||
|
Type "make build"
|
||||||
|
Enter
|
||||||
|
Sleep 5s
|
||||||
|
Show
|
||||||
|
|
||||||
|
# --- launch ------------------------------------------------------------------
|
||||||
|
Type "./bin/tm"
|
||||||
|
Enter
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# --- create tickets ----------------------------------------------------------
|
||||||
|
Type "n"
|
||||||
|
Sleep 400ms
|
||||||
|
Type "Design landing page"
|
||||||
|
Enter
|
||||||
|
Sleep 800ms
|
||||||
|
|
||||||
|
Type "n"
|
||||||
|
Sleep 400ms
|
||||||
|
Type "Wire up auth"
|
||||||
|
Enter
|
||||||
|
Sleep 800ms
|
||||||
|
|
||||||
|
Type "n"
|
||||||
|
Sleep 400ms
|
||||||
|
Type "Ship MVP checklist"
|
||||||
|
Enter
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# --- navigate to first ticket ------------------------------------------------
|
||||||
|
Type "k"
|
||||||
|
Sleep 400ms
|
||||||
|
Type "k"
|
||||||
|
Sleep 800ms
|
||||||
|
|
||||||
|
# --- edit ticket in vim (markdown + checklist) -------------------------------
|
||||||
|
Type "e"
|
||||||
|
Sleep 1s
|
||||||
|
# File opens as "# Design landing page" — append a markdown description
|
||||||
|
Type "Go"
|
||||||
|
Sleep 300ms
|
||||||
|
Enter
|
||||||
|
Type "## Goals"
|
||||||
|
Enter
|
||||||
|
Type "- [ ] Hero section with brand"
|
||||||
|
Enter
|
||||||
|
Type "- [ ] Primary CTA above the fold"
|
||||||
|
Enter
|
||||||
|
Type "- [ ] Keep the copy **short**"
|
||||||
|
Sleep 600ms
|
||||||
|
Escape
|
||||||
|
Sleep 400ms
|
||||||
|
Type ":wq"
|
||||||
|
Enter
|
||||||
|
Sleep 1.5s
|
||||||
|
# Expanded card shows checkboxes in the preview
|
||||||
|
Sleep 1.5s
|
||||||
|
|
||||||
|
# --- toggle checkboxes in the ticket preview ---------------------------------
|
||||||
|
# J/K (shift) move the checklist cursor; Space toggles — j/k still move tickets
|
||||||
|
Type "J"
|
||||||
|
Sleep 700ms
|
||||||
|
Space
|
||||||
|
Sleep 1s
|
||||||
|
Type "J"
|
||||||
|
Sleep 700ms
|
||||||
|
Space
|
||||||
|
Sleep 1s
|
||||||
|
Type "K"
|
||||||
|
Sleep 700ms
|
||||||
|
Space
|
||||||
|
Sleep 1.2s
|
||||||
|
|
||||||
|
# --- status picker (letter jump) ---------------------------------------------
|
||||||
|
Type "s"
|
||||||
|
Sleep 800ms
|
||||||
|
Type "w"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# peek another ticket, then back — checklist state stays on the edited card
|
||||||
|
Type "j"
|
||||||
|
Sleep 700ms
|
||||||
|
Type "k"
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# --- board filter (multiselect) ----------------------------------------------
|
||||||
|
Type "l"
|
||||||
|
Sleep 800ms
|
||||||
|
Type "b"
|
||||||
|
Sleep 300ms
|
||||||
|
Space
|
||||||
|
Sleep 400ms
|
||||||
|
Type "i"
|
||||||
|
Sleep 300ms
|
||||||
|
Space
|
||||||
|
Sleep 400ms
|
||||||
|
Enter
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# show all again
|
||||||
|
Type "l"
|
||||||
|
Sleep 600ms
|
||||||
|
Type "a"
|
||||||
|
Sleep 300ms
|
||||||
|
Enter
|
||||||
|
Sleep 800ms
|
||||||
|
|
||||||
|
# --- search / filter ---------------------------------------------------------
|
||||||
|
Type "/"
|
||||||
|
Sleep 400ms
|
||||||
|
Type "auth"
|
||||||
|
Enter
|
||||||
|
Sleep 1s
|
||||||
|
Type "/"
|
||||||
|
Sleep 300ms
|
||||||
|
Enter
|
||||||
|
Sleep 800ms
|
||||||
|
|
||||||
|
# --- projects: create + switch -----------------------------------------------
|
||||||
|
Type "p"
|
||||||
|
Sleep 800ms
|
||||||
|
Type "n"
|
||||||
|
Sleep 400ms
|
||||||
|
Type "client-acme"
|
||||||
|
Enter
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
Type "n"
|
||||||
|
Sleep 400ms
|
||||||
|
Type "Kickoff call notes"
|
||||||
|
Enter
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
Type "p"
|
||||||
|
Sleep 800ms
|
||||||
|
Type "i"
|
||||||
|
Sleep 400ms
|
||||||
|
Enter
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# --- timer -------------------------------------------------------------------
|
||||||
|
Type "j"
|
||||||
|
Sleep 300ms
|
||||||
|
Type "t"
|
||||||
|
Sleep 1.5s
|
||||||
|
Type "t"
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# --- manual time log ---------------------------------------------------------
|
||||||
|
Type "T"
|
||||||
|
Sleep 500ms
|
||||||
|
Type "45m"
|
||||||
|
Enter
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# --- manage time entries (edit duration) -------------------------------------
|
||||||
|
Type "m"
|
||||||
|
Sleep 1s
|
||||||
|
Type "e"
|
||||||
|
Sleep 500ms
|
||||||
|
Backspace 3
|
||||||
|
Type "1h15m"
|
||||||
|
Enter
|
||||||
|
Sleep 1s
|
||||||
|
Escape
|
||||||
|
Sleep 800ms
|
||||||
|
|
||||||
|
# --- report + group-by picker ------------------------------------------------
|
||||||
|
Type "r"
|
||||||
|
Sleep 1.2s
|
||||||
|
Type "g"
|
||||||
|
Sleep 800ms
|
||||||
|
Type "t"
|
||||||
|
Sleep 400ms
|
||||||
|
Enter
|
||||||
|
Sleep 1.2s
|
||||||
|
Type "g"
|
||||||
|
Sleep 600ms
|
||||||
|
Type "d"
|
||||||
|
Sleep 400ms
|
||||||
|
Enter
|
||||||
|
Sleep 1.2s
|
||||||
|
Escape
|
||||||
|
Sleep 800ms
|
||||||
|
|
||||||
|
# --- undo / redo -------------------------------------------------------------
|
||||||
|
Type "u"
|
||||||
|
Sleep 1s
|
||||||
|
Type "U"
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# --- delete with confirm -----------------------------------------------------
|
||||||
|
Type "n"
|
||||||
|
Sleep 400ms
|
||||||
|
Type "throwaway ticket"
|
||||||
|
Enter
|
||||||
|
Sleep 800ms
|
||||||
|
Type "x"
|
||||||
|
Sleep 700ms
|
||||||
|
Type "y"
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# --- force delete ------------------------------------------------------------
|
||||||
|
Type "n"
|
||||||
|
Sleep 400ms
|
||||||
|
Type "another draft"
|
||||||
|
Enter
|
||||||
|
Sleep 700ms
|
||||||
|
Type "X"
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# --- done --------------------------------------------------------------------
|
||||||
|
Sleep 1.5s
|
||||||
|
Type "q"
|
||||||
|
Sleep 500ms
|
||||||
@@ -26,6 +26,7 @@ type History struct {
|
|||||||
redoS []Command
|
redoS []Command
|
||||||
path string
|
path string
|
||||||
store ticket.Store
|
store ticket.Store
|
||||||
|
timeStore ticket.TimeStore
|
||||||
max int
|
max int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,13 +37,15 @@ func NewHistory() *History {
|
|||||||
|
|
||||||
// OpenHistory loads a persisted history from path (DataDir/history.json) or
|
// OpenHistory loads a persisted history from path (DataDir/history.json) or
|
||||||
// starts empty if the file is missing. Subsequent Do/Undo/Redo write back.
|
// starts empty if the file is missing. Subsequent Do/Undo/Redo write back.
|
||||||
func OpenHistory(path string, store ticket.Store) (*History, error) {
|
// timeStore may be nil if time-entry commands are unused.
|
||||||
|
func OpenHistory(path string, store ticket.Store, timeStore ticket.TimeStore) (*History, error) {
|
||||||
h := &History{
|
h := &History{
|
||||||
path: path,
|
path: path,
|
||||||
store: store,
|
store: store,
|
||||||
|
timeStore: timeStore,
|
||||||
max: MaxHistory,
|
max: MaxHistory,
|
||||||
}
|
}
|
||||||
undo, redo, err := loadHistoryFile(path, store)
|
undo, redo, err := loadHistoryFile(path, store, timeStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package command_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"task/internal/command"
|
||||||
|
"task/internal/ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMoveTicketUndo(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := ticket.NewJSONStore(dir)
|
||||||
|
timeStore := ticket.NewJSONTimeStore(dir)
|
||||||
|
h := command.NewHistory()
|
||||||
|
|
||||||
|
created, err := store.Create(ticket.Ticket{
|
||||||
|
Title: "Misfiled", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := timeStore.Create(ticket.TimeEntry{
|
||||||
|
TicketID: created.ID, ProjectID: "inbox", Duration: 15 * time.Minute, Source: ticket.SourceManual,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := created
|
||||||
|
after.ProjectID = "client"
|
||||||
|
if err := h.Do(&command.MoveTicket{
|
||||||
|
Store: store, TimeStore: timeStore, Before: created, After: after,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, _ := store.List("client")
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("client len=%d", len(list))
|
||||||
|
}
|
||||||
|
entries, _ := timeStore.ListByTicket(created.ID)
|
||||||
|
if len(entries) != 1 || entries[0].ProjectID != "client" {
|
||||||
|
t.Fatalf("time entries: %+v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.Undo(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, _ = store.List("inbox")
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("inbox after undo len=%d", len(list))
|
||||||
|
}
|
||||||
|
entries, _ = timeStore.ListByTicket(created.ID)
|
||||||
|
if len(entries) != 1 || entries[0].ProjectID != "inbox" {
|
||||||
|
t.Fatalf("time after undo: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,12 +26,19 @@ type persistedCmd struct {
|
|||||||
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
|
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
|
||||||
Before *ticket.Ticket `json:"before,omitempty"`
|
Before *ticket.Ticket `json:"before,omitempty"`
|
||||||
After *ticket.Ticket `json:"after,omitempty"`
|
After *ticket.Ticket `json:"after,omitempty"`
|
||||||
|
TimeEntry *ticket.TimeEntry `json:"timeEntry,omitempty"`
|
||||||
|
TimeBefore *ticket.TimeEntry `json:"timeBefore,omitempty"`
|
||||||
|
TimeAfter *ticket.TimeEntry `json:"timeAfter,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
typeCreate = "create"
|
typeCreate = "create"
|
||||||
typeDelete = "delete"
|
typeDelete = "delete"
|
||||||
typeUpdate = "update"
|
typeUpdate = "update"
|
||||||
|
typeAddTime = "add_time"
|
||||||
|
typeRemoveTime = "remove_time"
|
||||||
|
typeUpdateTime = "update_time"
|
||||||
|
typeMove = "move"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HistoryPath returns DataDir/history.json.
|
// HistoryPath returns DataDir/history.json.
|
||||||
@@ -54,12 +61,28 @@ func encodeCmd(cmd Command) (persistedCmd, error) {
|
|||||||
case *UpdateTicket:
|
case *UpdateTicket:
|
||||||
b, a := c.Before, c.After
|
b, a := c.Before, c.After
|
||||||
return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil
|
return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil
|
||||||
|
case *AddTimeEntry:
|
||||||
|
e := c.Entry
|
||||||
|
if c.created.ID != "" {
|
||||||
|
e = c.created
|
||||||
|
}
|
||||||
|
cp := e
|
||||||
|
return persistedCmd{Type: typeAddTime, TimeEntry: &cp}, nil
|
||||||
|
case *RemoveTimeEntry:
|
||||||
|
cp := c.Snapshot
|
||||||
|
return persistedCmd{Type: typeRemoveTime, TimeEntry: &cp}, nil
|
||||||
|
case *UpdateTimeEntry:
|
||||||
|
b, a := c.Before, c.After
|
||||||
|
return persistedCmd{Type: typeUpdateTime, TimeBefore: &b, TimeAfter: &a}, nil
|
||||||
|
case *MoveTicket:
|
||||||
|
b, a := c.Before, c.After
|
||||||
|
return persistedCmd{Type: typeMove, Before: &b, After: &a}, nil
|
||||||
default:
|
default:
|
||||||
return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd)
|
return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
|
func decodeCmd(rec persistedCmd, store ticket.Store, timeStore ticket.TimeStore) (Command, error) {
|
||||||
switch rec.Type {
|
switch rec.Type {
|
||||||
case typeCreate:
|
case typeCreate:
|
||||||
if rec.Ticket == nil {
|
if rec.Ticket == nil {
|
||||||
@@ -76,6 +99,35 @@ func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
|
|||||||
return nil, fmt.Errorf("update record missing before/after")
|
return nil, fmt.Errorf("update record missing before/after")
|
||||||
}
|
}
|
||||||
return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil
|
return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil
|
||||||
|
case typeAddTime:
|
||||||
|
if rec.TimeEntry == nil {
|
||||||
|
return nil, fmt.Errorf("add_time record missing entry")
|
||||||
|
}
|
||||||
|
if timeStore == nil {
|
||||||
|
return nil, fmt.Errorf("add_time: time store not configured")
|
||||||
|
}
|
||||||
|
return &AddTimeEntry{Store: timeStore, Entry: *rec.TimeEntry, created: *rec.TimeEntry}, nil
|
||||||
|
case typeRemoveTime:
|
||||||
|
if rec.TimeEntry == nil {
|
||||||
|
return nil, fmt.Errorf("remove_time record missing entry")
|
||||||
|
}
|
||||||
|
if timeStore == nil {
|
||||||
|
return nil, fmt.Errorf("remove_time: time store not configured")
|
||||||
|
}
|
||||||
|
return &RemoveTimeEntry{Store: timeStore, Snapshot: *rec.TimeEntry}, nil
|
||||||
|
case typeUpdateTime:
|
||||||
|
if rec.TimeBefore == nil || rec.TimeAfter == nil {
|
||||||
|
return nil, fmt.Errorf("update_time record missing before/after")
|
||||||
|
}
|
||||||
|
if timeStore == nil {
|
||||||
|
return nil, fmt.Errorf("update_time: time store not configured")
|
||||||
|
}
|
||||||
|
return &UpdateTimeEntry{Store: timeStore, Before: *rec.TimeBefore, After: *rec.TimeAfter}, nil
|
||||||
|
case typeMove:
|
||||||
|
if rec.Before == nil || rec.After == nil {
|
||||||
|
return nil, fmt.Errorf("move record missing before/after")
|
||||||
|
}
|
||||||
|
return &MoveTicket{Store: store, TimeStore: timeStore, Before: *rec.Before, After: *rec.After}, nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown command type %q", rec.Type)
|
return nil, fmt.Errorf("unknown command type %q", rec.Type)
|
||||||
}
|
}
|
||||||
@@ -93,10 +145,10 @@ func encodeStack(cmds []Command) ([]persistedCmd, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeStack(recs []persistedCmd, store ticket.Store) ([]Command, error) {
|
func decodeStack(recs []persistedCmd, store ticket.Store, timeStore ticket.TimeStore) ([]Command, error) {
|
||||||
out := make([]Command, 0, len(recs))
|
out := make([]Command, 0, len(recs))
|
||||||
for _, rec := range recs {
|
for _, rec := range recs {
|
||||||
cmd, err := decodeCmd(rec, store)
|
cmd, err := decodeCmd(rec, store, timeStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -154,7 +206,7 @@ func (h *History) persistLocked() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err error) {
|
func loadHistoryFile(path string, store ticket.Store, timeStore ticket.TimeStore) (undo, redo []Command, err error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -169,11 +221,11 @@ func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err
|
|||||||
if err := json.Unmarshal(data, &file); err != nil {
|
if err := json.Unmarshal(data, &file); err != nil {
|
||||||
return nil, nil, fmt.Errorf("parse history: %w", err)
|
return nil, nil, fmt.Errorf("parse history: %w", err)
|
||||||
}
|
}
|
||||||
undo, err = decodeStack(file.Undo, store)
|
undo, err = decodeStack(file.Undo, store, timeStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
redo, err = decodeStack(file.Redo, store)
|
redo, err = decodeStack(file.Redo, store, timeStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
|
|||||||
store := ticket.NewJSONStore(dir)
|
store := ticket.NewJSONStore(dir)
|
||||||
path := command.HistoryPath(dir)
|
path := command.HistoryPath(dir)
|
||||||
|
|
||||||
h1, err := command.OpenHistory(path, store)
|
h1, err := command.OpenHistory(path, store, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
|
|||||||
t.Fatalf("history file missing: %v", err)
|
t.Fatalf("history file missing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h2, err := command.OpenHistory(path, store)
|
h2, err := command.OpenHistory(path, store, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
|
|||||||
func TestHistoryCapsAtMax(t *testing.T) {
|
func TestHistoryCapsAtMax(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
store := ticket.NewJSONStore(dir)
|
store := ticket.NewJSONStore(dir)
|
||||||
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store)
|
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,3 +79,43 @@ func (c *UpdateTicket) Undo(ctx context.Context) error {
|
|||||||
_ = ctx
|
_ = ctx
|
||||||
return c.Store.Update(c.Before)
|
return c.Store.Update(c.Before)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveTicket relocates a ticket to another project; Undo moves it back.
|
||||||
|
type MoveTicket struct {
|
||||||
|
Store ticket.Store
|
||||||
|
TimeStore ticket.TimeStore
|
||||||
|
Before ticket.Ticket
|
||||||
|
After ticket.Ticket // ProjectID is the destination; other fields from Before
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MoveTicket) Name() string { return "move ticket" }
|
||||||
|
|
||||||
|
func (c *MoveTicket) Execute(ctx context.Context) error {
|
||||||
|
_ = ctx
|
||||||
|
moved, err := c.Store.Move(c.Before.ID, c.After.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.After = moved
|
||||||
|
if c.TimeStore != nil {
|
||||||
|
if err := c.TimeStore.RetargetTicket(moved.ID, moved.ProjectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MoveTicket) Undo(ctx context.Context) error {
|
||||||
|
_ = ctx
|
||||||
|
moved, err := c.Store.Move(c.After.ID, c.Before.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Before = moved
|
||||||
|
if c.TimeStore != nil {
|
||||||
|
if err := c.TimeStore.RetargetTicket(moved.ID, moved.ProjectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,16 @@ package config
|
|||||||
import "path/filepath"
|
import "path/filepath"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// AppName is used for config/data directory names under XDG paths.
|
||||||
AppName = "task-master"
|
AppName = "task-master"
|
||||||
|
// CliName is the installed binary / command name.
|
||||||
|
CliName = "tm"
|
||||||
DefaultProject = "inbox"
|
DefaultProject = "inbox"
|
||||||
DefaultTheme = "default"
|
DefaultTheme = "default"
|
||||||
configFileName = "config.json"
|
configFileName = "config.json"
|
||||||
|
|
||||||
|
envHome = "TASK_MASTER_HOME"
|
||||||
|
envDataDir = "TASK_MASTER_DATA_DIR"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds application settings persisted under ~/.config/task-master.
|
// Config holds application settings persisted under ~/.config/task-master.
|
||||||
@@ -14,7 +20,8 @@ type Config struct {
|
|||||||
ActiveProject string `json:"activeProject"`
|
ActiveProject string `json:"activeProject"`
|
||||||
DataDir string `json:"dataDir"`
|
DataDir string `json:"dataDir"`
|
||||||
Theme string `json:"theme"`
|
Theme string `json:"theme"`
|
||||||
// ProjectLists maps project ID → board list id (all|backlog|in_progress|done).
|
// ProjectLists maps project ID → comma-separated board list ids
|
||||||
|
// (e.g. "all" or "backlog,in_progress").
|
||||||
ProjectLists map[string]string `json:"projectLists,omitempty"`
|
ProjectLists map[string]string `json:"projectLists,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +62,8 @@ func (c *Config) ApplyDefaults(home string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveBoardList returns the persisted board list for a project (default: all).
|
// ActiveBoardLists returns the persisted board filter string for a project (default: "all").
|
||||||
func (c *Config) ActiveBoardList(projectID string) string {
|
func (c *Config) ActiveBoardLists(projectID string) string {
|
||||||
if c.ProjectLists == nil {
|
if c.ProjectLists == nil {
|
||||||
return "all"
|
return "all"
|
||||||
}
|
}
|
||||||
@@ -66,10 +73,18 @@ func (c *Config) ActiveBoardList(projectID string) string {
|
|||||||
return "all"
|
return "all"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActiveBoardList stores the board list for a project.
|
// SetActiveBoardLists stores the board filter string for a project.
|
||||||
func (c *Config) SetActiveBoardList(projectID, listID string) {
|
func (c *Config) SetActiveBoardLists(projectID, listIDs string) {
|
||||||
if c.ProjectLists == nil {
|
if c.ProjectLists == nil {
|
||||||
c.ProjectLists = map[string]string{}
|
c.ProjectLists = map[string]string{}
|
||||||
}
|
}
|
||||||
c.ProjectLists[projectID] = listID
|
c.ProjectLists[projectID] = listIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearProjectLists removes the persisted board filter for a project.
|
||||||
|
func (c *Config) ClearProjectLists(projectID string) {
|
||||||
|
if c.ProjectLists == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(c.ProjectLists, projectID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,28 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ResolveHome returns the effective home for config/data paths.
|
||||||
|
// TASK_MASTER_HOME overrides the real user home (useful for demos/dev sandboxes).
|
||||||
|
func ResolveHome() (string, error) {
|
||||||
|
if h := strings.TrimSpace(os.Getenv(envHome)); h != "" {
|
||||||
|
return filepath.Clean(h), nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||||
|
}
|
||||||
|
return home, nil
|
||||||
|
}
|
||||||
|
|
||||||
// LoadOrCreate loads the config from disk, creating defaults if missing.
|
// LoadOrCreate loads the config from disk, creating defaults if missing.
|
||||||
// It also ensures DataDir and the active project directory exist.
|
// It also ensures DataDir and the active project directory exist.
|
||||||
func LoadOrCreate() (*Config, error) {
|
func LoadOrCreate() (*Config, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := ResolveHome()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("resolve home directory: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgPath := Path(home)
|
cfgPath := Path(home)
|
||||||
@@ -26,6 +40,10 @@ func LoadOrCreate() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if d := strings.TrimSpace(os.Getenv(envDataDir)); d != "" {
|
||||||
|
cfg.DataDir = filepath.Clean(d)
|
||||||
|
}
|
||||||
|
|
||||||
if err := ensureDataDirs(cfg); err != nil {
|
if err := ensureDataDirs(cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -54,11 +72,12 @@ func readOrCreate(cfgPath, home string) (*Config, error) {
|
|||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save writes the config to ~/.config/task-master/config.json.
|
// Save writes the config to ~/.config/task-master/config.json
|
||||||
|
// (or under TASK_MASTER_HOME when set).
|
||||||
func Save(cfg *Config) error {
|
func Save(cfg *Config) error {
|
||||||
home, err := os.UserHomeDir()
|
home, err := ResolveHome()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve home directory: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgPath := Path(home)
|
cfgPath := Path(home)
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ func TestDefaultsAndApplyDefaults(t *testing.T) {
|
|||||||
func TestSaveRoundTrip(t *testing.T) {
|
func TestSaveRoundTrip(t *testing.T) {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("TASK_MASTER_HOME", "")
|
||||||
|
t.Setenv("TASK_MASTER_DATA_DIR", "")
|
||||||
|
|
||||||
cfg := config.Defaults(home)
|
cfg := config.Defaults(home)
|
||||||
cfg.ActiveProject = "work"
|
cfg.ActiveProject = "work"
|
||||||
@@ -54,6 +56,8 @@ func TestSaveRoundTrip(t *testing.T) {
|
|||||||
func TestLoadOrCreate(t *testing.T) {
|
func TestLoadOrCreate(t *testing.T) {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("TASK_MASTER_HOME", "")
|
||||||
|
t.Setenv("TASK_MASTER_DATA_DIR", "")
|
||||||
|
|
||||||
cfg, err := config.LoadOrCreate()
|
cfg, err := config.LoadOrCreate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,3 +85,38 @@ func TestLoadOrCreate(t *testing.T) {
|
|||||||
t.Fatalf("DataDir mismatch")
|
t.Fatalf("DataDir mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTASK_MASTER_HOME(t *testing.T) {
|
||||||
|
sandbox := t.TempDir()
|
||||||
|
t.Setenv("TASK_MASTER_HOME", sandbox)
|
||||||
|
t.Setenv("TASK_MASTER_DATA_DIR", "")
|
||||||
|
|
||||||
|
cfg, err := config.LoadOrCreate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadOrCreate: %v", err)
|
||||||
|
}
|
||||||
|
wantCfg := config.Path(sandbox)
|
||||||
|
if _, err := os.Stat(wantCfg); err != nil {
|
||||||
|
t.Fatalf("config under sandbox missing: %v", err)
|
||||||
|
}
|
||||||
|
wantData := filepath.Join(sandbox, ".local", "share", config.AppName)
|
||||||
|
if cfg.DataDir != wantData {
|
||||||
|
t.Fatalf("DataDir = %q, want %q", cfg.DataDir, wantData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTASK_MASTER_DATA_DIR(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
data := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("TASK_MASTER_HOME", "")
|
||||||
|
t.Setenv("TASK_MASTER_DATA_DIR", data)
|
||||||
|
|
||||||
|
cfg, err := config.LoadOrCreate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadOrCreate: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.DataDir != data {
|
||||||
|
t.Fatalf("DataDir = %q, want %q", cfg.DataDir, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
type Store interface {
|
type Store interface {
|
||||||
List() ([]Project, error)
|
List() ([]Project, error)
|
||||||
Ensure(id string) error
|
Ensure(id string) error
|
||||||
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// DirStore discovers projects as subdirectories of dataDir/projects.
|
// DirStore discovers projects as subdirectories of dataDir/projects.
|
||||||
@@ -63,3 +64,25 @@ func (s *DirStore) Ensure(id string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes the project directory and all of its files.
|
||||||
|
func (s *DirStore) Delete(id string) error {
|
||||||
|
if id == "" {
|
||||||
|
return fmt.Errorf("project id is required")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(s.projectsRoot(), id)
|
||||||
|
st, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("project %s: not found", id)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stat project %s: %w", id, err)
|
||||||
|
}
|
||||||
|
if !st.IsDir() {
|
||||||
|
return fmt.Errorf("project %s: not a directory", id)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
return fmt.Errorf("delete project %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package project_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"task/internal/project"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDirStoreDelete(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := project.NewDirStore(dir)
|
||||||
|
|
||||||
|
if err := store.Ensure("inbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Ensure("client"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Drop a marker file so RemoveAll has something to wipe.
|
||||||
|
marker := filepath.Join(dir, "projects", "client", "tickets.json")
|
||||||
|
if err := os.WriteFile(marker, []byte("[]\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := store.List()
|
||||||
|
if err != nil || len(list) != 2 {
|
||||||
|
t.Fatalf("list before: %v len=%d", err, len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Delete("client"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err = store.List()
|
||||||
|
if err != nil || len(list) != 1 || list[0].ID != "inbox" {
|
||||||
|
t.Fatalf("list after: %v %+v", err, list)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "projects", "client")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("client dir still present: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Delete("missing"); err == nil {
|
||||||
|
t.Fatal("expected error deleting missing project")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
package ticket
|
package ticket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
// BoardList is a project-scoped view/filter over tickets by status.
|
// BoardList is a project-scoped view/filter over tickets by status.
|
||||||
type BoardList string
|
type BoardList string
|
||||||
|
|
||||||
@@ -29,6 +34,15 @@ func (b BoardList) Label() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shortcut returns the board-menu hotkey (first letter of the label).
|
||||||
|
func (b BoardList) Shortcut() rune {
|
||||||
|
label := b.Label()
|
||||||
|
if label == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return unicode.ToLower([]rune(label)[0])
|
||||||
|
}
|
||||||
|
|
||||||
// Index returns the position in AllBoardLists.
|
// Index returns the position in AllBoardLists.
|
||||||
func (b BoardList) Index() int {
|
func (b BoardList) Index() int {
|
||||||
for i, bl := range AllBoardLists {
|
for i, bl := range AllBoardLists {
|
||||||
@@ -50,6 +64,114 @@ func ParseBoardList(s string) BoardList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BoardFilter is a multi-select set of board lists (OR semantics).
|
||||||
|
type BoardFilter []BoardList
|
||||||
|
|
||||||
|
// ParseBoardLists parses a comma-separated list id string (legacy single values work).
|
||||||
|
func ParseBoardLists(s string) BoardFilter {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return BoardFilter{BoardAll}
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
var out BoardFilter
|
||||||
|
seen := map[BoardList]bool{}
|
||||||
|
for _, p := range parts {
|
||||||
|
bl := ParseBoardList(strings.TrimSpace(p))
|
||||||
|
if seen[bl] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[bl] = true
|
||||||
|
out = append(out, bl)
|
||||||
|
}
|
||||||
|
return NormalizeFilter(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBoardLists serializes a filter for config persistence.
|
||||||
|
func FormatBoardLists(f BoardFilter) string {
|
||||||
|
f = NormalizeFilter(f)
|
||||||
|
parts := make([]string, len(f))
|
||||||
|
for i, bl := range f {
|
||||||
|
parts[i] = string(bl)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeFilter ensures a non-empty filter; All alone if empty or only All.
|
||||||
|
// If All is combined with others, All is dropped.
|
||||||
|
func NormalizeFilter(f BoardFilter) BoardFilter {
|
||||||
|
if len(f) == 0 {
|
||||||
|
return BoardFilter{BoardAll}
|
||||||
|
}
|
||||||
|
hasAll := false
|
||||||
|
var others BoardFilter
|
||||||
|
seen := map[BoardList]bool{}
|
||||||
|
for _, bl := range f {
|
||||||
|
if bl == BoardAll {
|
||||||
|
hasAll = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[bl] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[bl] = true
|
||||||
|
others = append(others, bl)
|
||||||
|
}
|
||||||
|
if len(others) > 0 {
|
||||||
|
return others
|
||||||
|
}
|
||||||
|
if hasAll {
|
||||||
|
return BoardFilter{BoardAll}
|
||||||
|
}
|
||||||
|
return BoardFilter{BoardAll}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether bl is in the filter.
|
||||||
|
func (f BoardFilter) Contains(bl BoardList) bool {
|
||||||
|
for _, x := range f {
|
||||||
|
if x == bl {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label returns a short header label for the active filter.
|
||||||
|
func (f BoardFilter) Label() string {
|
||||||
|
f = NormalizeFilter(f)
|
||||||
|
if len(f) == 1 {
|
||||||
|
return f[0].Label()
|
||||||
|
}
|
||||||
|
parts := make([]string, len(f))
|
||||||
|
for i, bl := range f {
|
||||||
|
parts[i] = bl.Label()
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "+")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches reports whether a ticket status is visible under this filter.
|
||||||
|
func (f BoardFilter) Matches(status Status) bool {
|
||||||
|
f = NormalizeFilter(f)
|
||||||
|
if f.Contains(BoardAll) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, bl := range f {
|
||||||
|
if bl.Matches(status) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStatus is used when creating a ticket under this filter.
|
||||||
|
func (f BoardFilter) DefaultStatus() Status {
|
||||||
|
f = NormalizeFilter(f)
|
||||||
|
if len(f) == 1 {
|
||||||
|
return f[0].DefaultStatus()
|
||||||
|
}
|
||||||
|
return StatusTodo
|
||||||
|
}
|
||||||
|
|
||||||
// Matches reports whether a ticket belongs on this board list.
|
// Matches reports whether a ticket belongs on this board list.
|
||||||
func (b BoardList) Matches(status Status) bool {
|
func (b BoardList) Matches(status Status) bool {
|
||||||
switch b {
|
switch b {
|
||||||
|
|||||||
@@ -41,3 +41,38 @@ func TestBoardListDefaultStatus(t *testing.T) {
|
|||||||
t.Fatal("all default")
|
t.Fatal("all default")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBoardFilterMultiSelect(t *testing.T) {
|
||||||
|
f := ticket.ParseBoardLists("backlog,done")
|
||||||
|
if !f.Matches(ticket.StatusTodo) || !f.Matches(ticket.StatusDone) {
|
||||||
|
t.Fatalf("expected backlog+done match, got %v", f)
|
||||||
|
}
|
||||||
|
if f.Matches(ticket.StatusInProgress) {
|
||||||
|
t.Fatal("should not match in progress")
|
||||||
|
}
|
||||||
|
if got := ticket.FormatBoardLists(f); got != "backlog,done" {
|
||||||
|
t.Fatalf("format = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardFilterAllDominatesNormalize(t *testing.T) {
|
||||||
|
f := ticket.NormalizeFilter(ticket.BoardFilter{ticket.BoardAll, ticket.BoardBacklog})
|
||||||
|
if len(f) != 1 || f[0] != ticket.BoardBacklog {
|
||||||
|
t.Fatalf("got %v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardListShortcut(t *testing.T) {
|
||||||
|
if ticket.BoardAll.Shortcut() != 'a' {
|
||||||
|
t.Fatal("all")
|
||||||
|
}
|
||||||
|
if ticket.BoardBacklog.Shortcut() != 'b' {
|
||||||
|
t.Fatal("backlog")
|
||||||
|
}
|
||||||
|
if ticket.BoardInProgress.Shortcut() != 'i' {
|
||||||
|
t.Fatal("in progress")
|
||||||
|
}
|
||||||
|
if ticket.BoardDone.Shortcut() != 'd' {
|
||||||
|
t.Fatal("done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package ticket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// taskCheckboxRe matches GFM/YouTrack-style task list items:
|
||||||
|
//
|
||||||
|
// - [ ] unchecked
|
||||||
|
// - [x] checked
|
||||||
|
// * [X] also accepted
|
||||||
|
var taskCheckboxRe = regexp.MustCompile(`^(\s*)([-*+])\s+\[([ xX])\](\s*)(.*)$`)
|
||||||
|
|
||||||
|
// Checkbox is one task-list item inside a Markdown description.
|
||||||
|
type Checkbox struct {
|
||||||
|
Line int // 0-based line index in the source
|
||||||
|
Checked bool
|
||||||
|
Text string // label after the checkbox marker
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCheckboxes returns all task-list items in markdown source order.
|
||||||
|
func ListCheckboxes(markdown string) []Checkbox {
|
||||||
|
if markdown == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lines := strings.Split(markdown, "\n")
|
||||||
|
var out []Checkbox
|
||||||
|
for i, line := range lines {
|
||||||
|
m := taskCheckboxRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Checkbox{
|
||||||
|
Line: i,
|
||||||
|
Checked: m[3] == "x" || m[3] == "X",
|
||||||
|
Text: m[5],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleCheckbox flips the checked state of the checkbox at index
|
||||||
|
// (0-based among ListCheckboxes results) and returns the updated markdown.
|
||||||
|
func ToggleCheckbox(markdown string, index int) (string, error) {
|
||||||
|
boxes := ListCheckboxes(markdown)
|
||||||
|
if index < 0 || index >= len(boxes) {
|
||||||
|
return markdown, fmt.Errorf("checkbox %d: not found", index)
|
||||||
|
}
|
||||||
|
lines := strings.Split(markdown, "\n")
|
||||||
|
lineIdx := boxes[index].Line
|
||||||
|
m := taskCheckboxRe.FindStringSubmatch(lines[lineIdx])
|
||||||
|
if m == nil {
|
||||||
|
return markdown, fmt.Errorf("checkbox %d: line changed", index)
|
||||||
|
}
|
||||||
|
mark := " "
|
||||||
|
if !(m[3] == "x" || m[3] == "X") {
|
||||||
|
mark = "x"
|
||||||
|
}
|
||||||
|
lines[lineIdx] = m[1] + m[2] + " [" + mark + "]" + m[4] + m[5]
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package ticket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListCheckboxes(t *testing.T) {
|
||||||
|
src := `# Steps
|
||||||
|
|
||||||
|
- [ ] one
|
||||||
|
- [x] two
|
||||||
|
* [X] three
|
||||||
|
- [ ] nested
|
||||||
|
|
||||||
|
Not a box: [ ] plain
|
||||||
|
`
|
||||||
|
got := ListCheckboxes(src)
|
||||||
|
if len(got) != 4 {
|
||||||
|
t.Fatalf("len=%d want 4: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Text != "one" || got[0].Checked || got[0].Line != 2 {
|
||||||
|
t.Fatalf("first: %+v", got[0])
|
||||||
|
}
|
||||||
|
if !got[1].Checked || got[1].Text != "two" {
|
||||||
|
t.Fatalf("second: %+v", got[1])
|
||||||
|
}
|
||||||
|
if !got[2].Checked || got[2].Text != "three" {
|
||||||
|
t.Fatalf("third: %+v", got[2])
|
||||||
|
}
|
||||||
|
if got[3].Text != "nested" || got[3].Checked || got[3].Line != 5 {
|
||||||
|
t.Fatalf("nested: %+v", got[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleCheckbox(t *testing.T) {
|
||||||
|
src := "- [ ] a\n- [x] b\n"
|
||||||
|
out, err := ToggleCheckbox(src, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != "- [x] a\n- [x] b\n" {
|
||||||
|
t.Fatalf("toggle 0: %q", out)
|
||||||
|
}
|
||||||
|
out, err = ToggleCheckbox(out, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != "- [x] a\n- [ ] b\n" {
|
||||||
|
t.Fatalf("toggle 1: %q", out)
|
||||||
|
}
|
||||||
|
if _, err := ToggleCheckbox(src, 9); err == nil {
|
||||||
|
t.Fatal("expected error for bad index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleCheckboxPreservesIndentAndBullet(t *testing.T) {
|
||||||
|
src := " * [X] deep"
|
||||||
|
out, err := ToggleCheckbox(src, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != " * [ ] deep" {
|
||||||
|
t.Fatalf("got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,6 +146,73 @@ func (s *JSONStore) Update(t Ticket) error {
|
|||||||
return s.writeUnlocked(t.ProjectID, tickets)
|
return s.writeUnlocked(t.ProjectID, tickets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move relocates a ticket to another project directory.
|
||||||
|
func (s *JSONStore) Move(id, toProjectID string) (Ticket, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
return Ticket{}, fmt.Errorf("ticket id is required")
|
||||||
|
}
|
||||||
|
if toProjectID == "" {
|
||||||
|
return Ticket{}, fmt.Errorf("projectId is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||||
|
}
|
||||||
|
return Ticket{}, fmt.Errorf("list projects: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found Ticket
|
||||||
|
fromProject := ""
|
||||||
|
for _, p := range projects {
|
||||||
|
if !p.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tickets, err := s.readUnlocked(p.Name())
|
||||||
|
if err != nil {
|
||||||
|
return Ticket{}, err
|
||||||
|
}
|
||||||
|
for i, t := range tickets {
|
||||||
|
if t.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = t
|
||||||
|
fromProject = p.Name()
|
||||||
|
tickets = append(tickets[:i], tickets[i+1:]...)
|
||||||
|
if err := s.writeUnlocked(fromProject, tickets); err != nil {
|
||||||
|
return Ticket{}, err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if fromProject != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fromProject == "" {
|
||||||
|
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
found.ProjectID = toProjectID
|
||||||
|
found.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(s.projectPath(toProjectID), 0o755); err != nil {
|
||||||
|
return Ticket{}, fmt.Errorf("create project dir: %w", err)
|
||||||
|
}
|
||||||
|
dest, err := s.readUnlocked(toProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return Ticket{}, err
|
||||||
|
}
|
||||||
|
dest = append(dest, found)
|
||||||
|
if err := s.writeUnlocked(toProjectID, dest); err != nil {
|
||||||
|
return Ticket{}, err
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Delete removes a ticket by ID from its project's file.
|
// Delete removes a ticket by ID from its project's file.
|
||||||
func (s *JSONStore) Delete(id string) error {
|
func (s *JSONStore) Delete(id string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@@ -97,3 +97,33 @@ func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
|
|||||||
t.Fatalf("legacy tasks.json should be removed, err=%v", err)
|
t.Fatalf("legacy tasks.json should be removed, err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJSONStoreMove(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := ticket.NewJSONStore(dir)
|
||||||
|
|
||||||
|
created, err := store.Create(ticket.Ticket{
|
||||||
|
Title: "Misfiled",
|
||||||
|
ProjectID: "inbox",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
moved, err := store.Move(created.ID, "client")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if moved.ProjectID != "client" {
|
||||||
|
t.Fatalf("ProjectID = %q", moved.ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
from, err := store.List("inbox")
|
||||||
|
if err != nil || len(from) != 0 {
|
||||||
|
t.Fatalf("inbox after move: %v len=%d", err, len(from))
|
||||||
|
}
|
||||||
|
to, err := store.List("client")
|
||||||
|
if err != nil || len(to) != 1 || to[0].ID != created.ID {
|
||||||
|
t.Fatalf("client after move: %v %+v", err, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetargetTicket sets ProjectID on all entries for a ticket.
|
||||||
|
func (s *JSONTimeStore) RetargetTicket(ticketID, projectID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if ticketID == "" {
|
||||||
|
return fmt.Errorf("ticketId is required")
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
return fmt.Errorf("projectId is required")
|
||||||
|
}
|
||||||
|
entries, err := s.readUnlocked()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].TicketID == ticketID {
|
||||||
|
entries[i].ProjectID = projectID
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.writeUnlocked(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteByProject removes all time entries for a project.
|
||||||
|
func (s *JSONTimeStore) DeleteByProject(projectID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if projectID == "" {
|
||||||
|
return fmt.Errorf("projectId is required")
|
||||||
|
}
|
||||||
|
entries, err := s.readUnlocked()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := make([]TimeEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.ProjectID == projectID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONTimeStoreDeleteByProject(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := ticket.NewJSONTimeStore(dir)
|
||||||
|
|
||||||
|
for _, e := range []ticket.TimeEntry{
|
||||||
|
{TicketID: "a", ProjectID: "p1", Duration: time.Hour, Source: ticket.SourceManual},
|
||||||
|
{TicketID: "b", ProjectID: "p1", Duration: time.Minute, Source: ticket.SourceManual},
|
||||||
|
{TicketID: "c", ProjectID: "p2", Duration: 30 * time.Minute, Source: ticket.SourceManual},
|
||||||
|
} {
|
||||||
|
if _, err := store.Create(e); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := store.DeleteByProject("p1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := store.List()
|
||||||
|
if err != nil || len(list) != 1 || list[0].ProjectID != "p2" {
|
||||||
|
t.Fatalf("after DeleteByProject: %v %+v", err, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONTimeStoreRetargetTicket(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := ticket.NewJSONTimeStore(dir)
|
||||||
|
|
||||||
|
if _, err := store.Create(ticket.TimeEntry{
|
||||||
|
TicketID: "t1", ProjectID: "old", Duration: time.Hour, Source: ticket.SourceManual,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.Create(ticket.TimeEntry{
|
||||||
|
TicketID: "t2", ProjectID: "old", Duration: time.Minute, Source: ticket.SourceManual,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.RetargetTicket("t1", "new"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := store.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range list {
|
||||||
|
switch e.TicketID {
|
||||||
|
case "t1":
|
||||||
|
if e.ProjectID != "new" {
|
||||||
|
t.Fatalf("t1 project=%q", e.ProjectID)
|
||||||
|
}
|
||||||
|
case "t2":
|
||||||
|
if e.ProjectID != "old" {
|
||||||
|
t.Fatalf("t2 project=%q", e.ProjectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,6 @@ type Store interface {
|
|||||||
Get(id string) (Ticket, error)
|
Get(id string) (Ticket, error)
|
||||||
Create(t Ticket) (Ticket, error)
|
Create(t Ticket) (Ticket, error)
|
||||||
Update(t Ticket) error
|
Update(t Ticket) error
|
||||||
|
Move(id, toProjectID string) (Ticket, error)
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package ticket
|
package ticket
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
// Status represents the workflow state of a ticket.
|
// Status represents the workflow state of a ticket.
|
||||||
type Status string
|
type Status string
|
||||||
@@ -28,6 +31,15 @@ func (s Status) Label() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shortcut returns the status-menu hotkey (first letter of the label).
|
||||||
|
func (s Status) Shortcut() rune {
|
||||||
|
label := s.Label()
|
||||||
|
if label == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return unicode.ToLower([]rune(label)[0])
|
||||||
|
}
|
||||||
|
|
||||||
// Index returns the position in AllStatuses, or 0 if unknown.
|
// Index returns the position in AllStatuses, or 0 if unknown.
|
||||||
func (s Status) Index() int {
|
func (s Status) Index() int {
|
||||||
for i, st := range AllStatuses {
|
for i, st := range AllStatuses {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
DeleteByProject(projectID string) error
|
||||||
|
RetargetTicket(ticketID, projectID string) error
|
||||||
|
SumByTicket(ticketID string) (time.Duration, error)
|
||||||
|
|
||||||
|
LoadActiveTimer() (*ActiveTimer, error)
|
||||||
|
SaveActiveTimer(t *ActiveTimer) error // nil clears
|
||||||
|
}
|
||||||
+835
-19
File diff suppressed because it is too large
Load Diff
+52
-1
@@ -30,19 +30,32 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
case ModeProjects:
|
case ModeProjects:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "navigate"},
|
{"j/k", "navigate"},
|
||||||
|
{"a-z", "jump"},
|
||||||
{"enter", "select"},
|
{"enter", "select"},
|
||||||
{"n", "new project"},
|
{"n", "new project"},
|
||||||
|
{"x", "delete"},
|
||||||
|
{"X", "force delete"},
|
||||||
|
{"esc", "cancel"},
|
||||||
|
}
|
||||||
|
case ModeMoveTicket:
|
||||||
|
return []helpEntry{
|
||||||
|
{"j/k", "navigate"},
|
||||||
|
{"a-z", "jump"},
|
||||||
|
{"enter", "move here"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
case ModeStatus:
|
case ModeStatus:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "select"},
|
{"j/k", "select"},
|
||||||
|
{"o/w/d", "jump"},
|
||||||
{"enter", "apply"},
|
{"enter", "apply"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
case ModeBoard:
|
case ModeBoard:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "select"},
|
{"j/k", "navigate"},
|
||||||
|
{"a/b/i/d", "jump"},
|
||||||
|
{"space", "toggle"},
|
||||||
{"enter", "apply"},
|
{"enter", "apply"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
@@ -51,14 +64,52 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
{"y/enter", "confirm"},
|
{"y/enter", "confirm"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
|
case ModeTimeManual:
|
||||||
|
return []helpEntry{
|
||||||
|
{"enter", "save"},
|
||||||
|
{"esc", "cancel"},
|
||||||
|
}
|
||||||
|
case ModeTimeEntries:
|
||||||
|
return []helpEntry{
|
||||||
|
{"j/k", "navigate"},
|
||||||
|
{"e", "edit"},
|
||||||
|
{"x", "delete"},
|
||||||
|
{"u/U", "undo/redo"},
|
||||||
|
{"esc", "back"},
|
||||||
|
}
|
||||||
|
case ModeTimeEdit:
|
||||||
|
return []helpEntry{
|
||||||
|
{"enter", "save"},
|
||||||
|
{"esc", "cancel"},
|
||||||
|
}
|
||||||
|
case ModeReport:
|
||||||
|
return []helpEntry{
|
||||||
|
{"g", "group"},
|
||||||
|
{"j/k", "scroll"},
|
||||||
|
{"esc", "back"},
|
||||||
|
}
|
||||||
|
case ModeReportGroup:
|
||||||
|
return []helpEntry{
|
||||||
|
{"j/k", "select"},
|
||||||
|
{"p/s/t/d", "jump"},
|
||||||
|
{"enter", "apply"},
|
||||||
|
{"esc", "cancel"},
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "navigate"},
|
{"j/k", "navigate"},
|
||||||
|
{"J/K", "checklist"},
|
||||||
|
{"S-↑/↓", "checklist"},
|
||||||
|
{"space", "check"},
|
||||||
{"n", "new"},
|
{"n", "new"},
|
||||||
{"e", "edit"},
|
{"e", "edit"},
|
||||||
|
{"t/T", "timer/log"},
|
||||||
|
{"m", "entries"},
|
||||||
|
{"r", "report"},
|
||||||
{"u/U", "undo/redo"},
|
{"u/U", "undo/redo"},
|
||||||
{"/", "search"},
|
{"/", "search"},
|
||||||
{"p", "project"},
|
{"p", "project"},
|
||||||
|
{"P", "move"},
|
||||||
{"l", "list"},
|
{"l", "list"},
|
||||||
{"s", "status"},
|
{"s", "status"},
|
||||||
{"x", "delete"},
|
{"x", "delete"},
|
||||||
|
|||||||
+41
-1
@@ -9,6 +9,7 @@ type KeyMap struct {
|
|||||||
New key.Binding
|
New key.Binding
|
||||||
Search key.Binding
|
Search key.Binding
|
||||||
Project key.Binding
|
Project key.Binding
|
||||||
|
MoveTicket key.Binding // P — move selected ticket to another project
|
||||||
Board key.Binding
|
Board key.Binding
|
||||||
Status key.Binding
|
Status key.Binding
|
||||||
Edit key.Binding
|
Edit key.Binding
|
||||||
@@ -16,6 +17,13 @@ type KeyMap struct {
|
|||||||
Delete key.Binding // x — confirm first
|
Delete key.Binding // x — confirm first
|
||||||
DeleteForce key.Binding // X — immediate
|
DeleteForce key.Binding // X — immediate
|
||||||
ConfirmYes key.Binding
|
ConfirmYes key.Binding
|
||||||
|
Toggle key.Binding // space — toggle multi-select / checklist item
|
||||||
|
ChecklistPrev key.Binding // K / shift+up — previous checkbox
|
||||||
|
ChecklistNext key.Binding // J / shift+down — next checkbox
|
||||||
|
Timer key.Binding // t — start/stop timer
|
||||||
|
TimeManual key.Binding // T — manual duration
|
||||||
|
TimeEntries key.Binding // m — manage time entries
|
||||||
|
Report key.Binding // r — time report
|
||||||
Undo key.Binding
|
Undo key.Binding
|
||||||
Redo key.Binding
|
Redo key.Binding
|
||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
@@ -46,6 +54,10 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithKeys("p"),
|
key.WithKeys("p"),
|
||||||
key.WithHelp("p", "project"),
|
key.WithHelp("p", "project"),
|
||||||
),
|
),
|
||||||
|
MoveTicket: key.NewBinding(
|
||||||
|
key.WithKeys("P"),
|
||||||
|
key.WithHelp("P", "move ticket"),
|
||||||
|
),
|
||||||
Board: key.NewBinding(
|
Board: key.NewBinding(
|
||||||
key.WithKeys("l", "tab"),
|
key.WithKeys("l", "tab"),
|
||||||
key.WithHelp("l", "list"),
|
key.WithHelp("l", "list"),
|
||||||
@@ -74,6 +86,34 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithKeys("y", "Y"),
|
key.WithKeys("y", "Y"),
|
||||||
key.WithHelp("y", "confirm"),
|
key.WithHelp("y", "confirm"),
|
||||||
),
|
),
|
||||||
|
Toggle: key.NewBinding(
|
||||||
|
key.WithKeys(" "),
|
||||||
|
key.WithHelp("space", "toggle"),
|
||||||
|
),
|
||||||
|
ChecklistPrev: key.NewBinding(
|
||||||
|
key.WithKeys("K", "shift+up"),
|
||||||
|
key.WithHelp("J/K", "checklist"),
|
||||||
|
),
|
||||||
|
ChecklistNext: key.NewBinding(
|
||||||
|
key.WithKeys("J", "shift+down"),
|
||||||
|
key.WithHelp("J/K", "checklist"),
|
||||||
|
),
|
||||||
|
Timer: key.NewBinding(
|
||||||
|
key.WithKeys("t"),
|
||||||
|
key.WithHelp("t", "timer"),
|
||||||
|
),
|
||||||
|
TimeManual: key.NewBinding(
|
||||||
|
key.WithKeys("T"),
|
||||||
|
key.WithHelp("T", "log time"),
|
||||||
|
),
|
||||||
|
TimeEntries: key.NewBinding(
|
||||||
|
key.WithKeys("m"),
|
||||||
|
key.WithHelp("m", "time entries"),
|
||||||
|
),
|
||||||
|
Report: key.NewBinding(
|
||||||
|
key.WithKeys("r"),
|
||||||
|
key.WithHelp("r", "report"),
|
||||||
|
),
|
||||||
Undo: key.NewBinding(
|
Undo: key.NewBinding(
|
||||||
key.WithKeys("u", "ctrl+z"),
|
key.WithKeys("u", "ctrl+z"),
|
||||||
key.WithHelp("u", "undo"),
|
key.WithHelp("u", "undo"),
|
||||||
@@ -99,7 +139,7 @@ func DefaultKeyMap() KeyMap {
|
|||||||
|
|
||||||
// ShortHelp implements help.KeyMap.
|
// ShortHelp implements help.KeyMap.
|
||||||
func (k KeyMap) ShortHelp() []key.Binding {
|
func (k KeyMap) ShortHelp() []key.Binding {
|
||||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FullHelp implements help.KeyMap.
|
// FullHelp implements help.KeyMap.
|
||||||
|
|||||||
+38
-4
@@ -10,12 +10,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) renderHeader() string {
|
func (m Model) renderHeader() string {
|
||||||
brand := m.styles.HeaderBrand.Render(config.AppName)
|
brand := m.styles.HeaderBrand.Render(config.CliName)
|
||||||
project := m.styles.HeaderMeta.Render(fmt.Sprintf("project: %s", m.cfg.ActiveProject))
|
project := m.styles.HeaderMeta.Render(fmt.Sprintf("project: %s", m.cfg.ActiveProject))
|
||||||
board := m.styles.FilterHint.Render(fmt.Sprintf("list: %s", m.boardList.Label()))
|
board := m.styles.FilterHint.Render(fmt.Sprintf("list: %s", m.boardFilter.Label()))
|
||||||
count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
|
count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
|
||||||
|
|
||||||
parts := []string{brand, project, board, count}
|
parts := []string{brand, project, board, count}
|
||||||
|
if label := m.timerHeaderLabel(); label != "" {
|
||||||
|
parts = append(parts, m.styles.FilterHint.Render(label))
|
||||||
|
}
|
||||||
if m.filter != "" {
|
if m.filter != "" {
|
||||||
parts = append(parts, m.styles.FilterHint.Render(fmt.Sprintf("filter: %q", m.filter)))
|
parts = append(parts, m.styles.FilterHint.Render(fmt.Sprintf("filter: %q", m.filter)))
|
||||||
}
|
}
|
||||||
@@ -51,7 +54,29 @@ func (m Model) renderBody(height int) string {
|
|||||||
content := prompt + "\n" + m.input.View()
|
content := prompt + "\n" + m.input.View()
|
||||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||||
|
|
||||||
case ModeProjects:
|
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, ModeMoveTicket:
|
||||||
m.projectList.SetSize(m.width, height)
|
m.projectList.SetSize(m.width, height)
|
||||||
return m.projectList.View()
|
return m.projectList.View()
|
||||||
|
|
||||||
@@ -66,9 +91,18 @@ func (m Model) renderBody(height int) string {
|
|||||||
case ModeConfirmDelete:
|
case ModeConfirmDelete:
|
||||||
title := m.pendingDeleteTitle
|
title := m.pendingDeleteTitle
|
||||||
if title == "" {
|
if title == "" {
|
||||||
|
if m.pendingDeleteProject != "" {
|
||||||
|
title = "this project"
|
||||||
|
} else {
|
||||||
title = "this ticket"
|
title = "this ticket"
|
||||||
}
|
}
|
||||||
prompt := m.styles.ErrorMsg.Render(fmt.Sprintf("Delete %q?", title))
|
}
|
||||||
|
var prompt string
|
||||||
|
if m.pendingDeleteProject != "" {
|
||||||
|
prompt = m.styles.ErrorMsg.Render(fmt.Sprintf("Delete %s?", title))
|
||||||
|
} else {
|
||||||
|
prompt = m.styles.ErrorMsg.Render(fmt.Sprintf("Delete %q?", title))
|
||||||
|
}
|
||||||
hint := m.styles.StatusMsg.Render("y/enter confirm · esc cancel")
|
hint := m.styles.StatusMsg.Render("y/enter confirm · esc cancel")
|
||||||
content := prompt + "\n" + hint
|
content := prompt + "\n" + hint
|
||||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||||
|
|||||||
+70
-15
@@ -1,24 +1,40 @@
|
|||||||
package views
|
package views
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"task/internal/ticket"
|
"task/internal/ticket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BoardMenu renders a board-list picker overlay.
|
// BoardMenu renders a multi-select board-list picker overlay.
|
||||||
type BoardMenu struct {
|
type BoardMenu struct {
|
||||||
Cursor int
|
Cursor int
|
||||||
Width int
|
Width int
|
||||||
Styles StatusMenuStyles
|
Styles StatusMenuStyles
|
||||||
|
selected map[ticket.BoardList]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBoardMenu creates a menu with the given board list pre-selected.
|
// NewBoardMenu creates a menu with the given filter pre-selected.
|
||||||
func NewBoardMenu(current ticket.BoardList, width int, styles StatusMenuStyles) BoardMenu {
|
func NewBoardMenu(current ticket.BoardFilter, width int, styles StatusMenuStyles) BoardMenu {
|
||||||
|
current = ticket.NormalizeFilter(current)
|
||||||
|
sel := map[ticket.BoardList]bool{}
|
||||||
|
for _, bl := range current {
|
||||||
|
sel[bl] = true
|
||||||
|
}
|
||||||
|
cursor := 0
|
||||||
|
for i, bl := range ticket.AllBoardLists {
|
||||||
|
if sel[bl] {
|
||||||
|
cursor = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
return BoardMenu{
|
return BoardMenu{
|
||||||
Cursor: current.Index(),
|
Cursor: cursor,
|
||||||
Width: width,
|
Width: width,
|
||||||
Styles: styles,
|
Styles: styles,
|
||||||
|
selected: sel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,31 +52,70 @@ func (m *BoardMenu) MoveUp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selected returns the currently highlighted board list.
|
// JumpToShortcut moves the cursor to the list whose label starts with r.
|
||||||
func (m BoardMenu) Selected() ticket.BoardList {
|
func (m *BoardMenu) JumpToShortcut(r rune) bool {
|
||||||
if m.Cursor < 0 || m.Cursor >= len(ticket.AllBoardLists) {
|
r = unicode.ToLower(r)
|
||||||
return ticket.BoardAll
|
for i, bl := range ticket.AllBoardLists {
|
||||||
|
if bl.Shortcut() == r {
|
||||||
|
m.Cursor = i
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return ticket.AllBoardLists[m.Cursor]
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle flips the checked state of the highlighted list.
|
||||||
|
func (m *BoardMenu) Toggle() {
|
||||||
|
if m.selected == nil {
|
||||||
|
m.selected = map[ticket.BoardList]bool{}
|
||||||
|
}
|
||||||
|
bl := ticket.AllBoardLists[m.Cursor]
|
||||||
|
if bl == ticket.BoardAll {
|
||||||
|
m.selected = map[ticket.BoardList]bool{ticket.BoardAll: true}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(m.selected, ticket.BoardAll)
|
||||||
|
if m.selected[bl] {
|
||||||
|
delete(m.selected, bl)
|
||||||
|
} else {
|
||||||
|
m.selected[bl] = true
|
||||||
|
}
|
||||||
|
if len(m.selected) == 0 {
|
||||||
|
m.selected[ticket.BoardAll] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected returns the current multi-select filter.
|
||||||
|
func (m BoardMenu) Selected() ticket.BoardFilter {
|
||||||
|
var out ticket.BoardFilter
|
||||||
|
for _, bl := range ticket.AllBoardLists {
|
||||||
|
if m.selected[bl] {
|
||||||
|
out = append(out, bl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ticket.NormalizeFilter(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// View renders the board menu.
|
// View renders the board menu.
|
||||||
func (m BoardMenu) View() string {
|
func (m BoardMenu) View() string {
|
||||||
var lines []string
|
var lines []string
|
||||||
lines = append(lines, m.Styles.Title.Render("Switch list"))
|
lines = append(lines, m.Styles.Title.Render("Filter lists"))
|
||||||
for i, bl := range ticket.AllBoardLists {
|
for i, bl := range ticket.AllBoardLists {
|
||||||
label := " " + bl.Label()
|
mark := "[ ]"
|
||||||
|
if m.selected[bl] {
|
||||||
|
mark = "[x]"
|
||||||
|
}
|
||||||
|
entry := fmt.Sprintf("%s %c %s", mark, bl.Shortcut(), bl.Label())
|
||||||
if i == m.Cursor {
|
if i == m.Cursor {
|
||||||
label = m.Styles.Selected.Render("▸ " + bl.Label())
|
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
|
||||||
} else {
|
} else {
|
||||||
label = m.Styles.Normal.Render(label)
|
lines = append(lines, m.Styles.Normal.Render(" "+entry))
|
||||||
}
|
}
|
||||||
lines = append(lines, label)
|
|
||||||
}
|
}
|
||||||
body := strings.Join(lines, "\n")
|
body := strings.Join(lines, "\n")
|
||||||
w := m.Width
|
w := m.Width
|
||||||
if w < 20 {
|
if w < 20 {
|
||||||
w = 20
|
w = 20
|
||||||
}
|
}
|
||||||
return m.Styles.Box.Width(min(40, w-2)).Render(body)
|
return m.Styles.Box.Width(min(44, w-2)).Render(body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,3 +12,12 @@ func NewCreateInput() textinput.Model {
|
|||||||
ti.Width = 60
|
ti.Width = 60
|
||||||
return ti
|
return ti
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTimeManualInput returns a text input for manual duration entry.
|
||||||
|
func NewTimeManualInput() textinput.Model {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = "Duration (1h30m, 90m, 1.5h)…"
|
||||||
|
ti.CharLimit = 32
|
||||||
|
ti.Width = 60
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
package views
|
package views
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/charmbracelet/glamour"
|
"github.com/charmbracelet/glamour"
|
||||||
"github.com/charmbracelet/glamour/styles"
|
"github.com/charmbracelet/glamour/styles"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/muesli/termenv"
|
"github.com/muesli/termenv"
|
||||||
|
|
||||||
|
"task/internal/ticket"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mdCacheKey struct {
|
type mdCacheKey struct {
|
||||||
@@ -79,6 +83,78 @@ func getRenderer(width int) (*glamour.TermRenderer, error) {
|
|||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderDescription renders ticket Markdown with interactive task-list items.
|
||||||
|
// checklistCursor is the 0-based index among checkboxes (highlight); ignored when
|
||||||
|
// there are none.
|
||||||
|
func RenderDescription(src string, width int, checklistCursor int) string {
|
||||||
|
if strings.TrimSpace(src) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
boxes := ticket.ListCheckboxes(src)
|
||||||
|
if len(boxes) == 0 {
|
||||||
|
return RenderMarkdown(src, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
boxByLine := make(map[int]int, len(boxes))
|
||||||
|
for i, b := range boxes {
|
||||||
|
boxByLine[b.Line] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(src, "\n")
|
||||||
|
var parts []string
|
||||||
|
var mdBuf []string
|
||||||
|
flushMD := func() {
|
||||||
|
if len(mdBuf) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
block := strings.Join(mdBuf, "\n")
|
||||||
|
if strings.TrimSpace(block) != "" {
|
||||||
|
parts = append(parts, RenderMarkdown(block, width))
|
||||||
|
}
|
||||||
|
mdBuf = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
if bi, ok := boxByLine[i]; ok {
|
||||||
|
flushMD()
|
||||||
|
parts = append(parts, renderCheckboxLine(boxes[bi], bi == checklistCursor, width))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mdBuf = append(mdBuf, line)
|
||||||
|
}
|
||||||
|
flushMD()
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCheckboxLine(box ticket.Checkbox, selected bool, width int) string {
|
||||||
|
// ASCII markers — widely supported; bold + padding reads a notch bigger.
|
||||||
|
mark := "[ ]"
|
||||||
|
if box.Checked {
|
||||||
|
mark = "[x]"
|
||||||
|
}
|
||||||
|
prefix := " "
|
||||||
|
if selected {
|
||||||
|
prefix = "> "
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(box.Text)
|
||||||
|
avail := max(10, width) - lipgloss.Width(prefix+mark+" ")
|
||||||
|
label = truncateOneLine(label, avail)
|
||||||
|
|
||||||
|
markStyle := lipgloss.NewStyle().Bold(true)
|
||||||
|
labelStyle := lipgloss.NewStyle()
|
||||||
|
if selected {
|
||||||
|
markStyle = markStyle.Foreground(lipgloss.Color("39"))
|
||||||
|
labelStyle = labelStyle.Bold(true).Foreground(lipgloss.Color("39"))
|
||||||
|
} else if box.Checked {
|
||||||
|
labelStyle = labelStyle.Faint(true)
|
||||||
|
}
|
||||||
|
line := prefix + markStyle.Render(mark) + " " + labelStyle.Render(label)
|
||||||
|
return lipgloss.NewStyle().Width(max(10, width)).Render(line)
|
||||||
|
}
|
||||||
|
|
||||||
// ClearMarkdownCache drops cached renders (e.g. after theme change).
|
// ClearMarkdownCache drops cached renders (e.g. after theme change).
|
||||||
func ClearMarkdownCache() {
|
func ClearMarkdownCache() {
|
||||||
mdMu.Lock()
|
mdMu.Lock()
|
||||||
|
|||||||
@@ -30,3 +30,14 @@ func TestRenderMarkdownFastWithoutAutoStyle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Logf("first=%v cached=%v", elapsed, time.Since(start))
|
t.Logf("first=%v cached=%v", elapsed, time.Since(start))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderDescriptionHighlightsCheckbox(t *testing.T) {
|
||||||
|
src := "Intro\n\n- [ ] one\n- [x] two\n"
|
||||||
|
out := views.RenderDescription(src, 40, 0)
|
||||||
|
if !strings.Contains(out, "one") || !strings.Contains(out, "[ ]") {
|
||||||
|
t.Fatalf("expected unchecked item in output: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "two") || !strings.Contains(out, "[x]") {
|
||||||
|
t.Fatalf("expected checked item in output: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package views
|
package views
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
|
||||||
"task/internal/project"
|
"task/internal/project"
|
||||||
@@ -46,6 +48,33 @@ func NewProjectList(projects []project.Project, activeID string, width, height i
|
|||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JumpProjectByLetter cycles to the next project whose name starts with r.
|
||||||
|
func JumpProjectByLetter(l *list.Model, r rune) bool {
|
||||||
|
r = unicode.ToLower(r)
|
||||||
|
items := l.Items()
|
||||||
|
n := len(items)
|
||||||
|
if n == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
start := l.Index()
|
||||||
|
for step := 1; step <= n; step++ {
|
||||||
|
i := (start + step) % n
|
||||||
|
pi, ok := items[i].(ProjectItem)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := []rune(pi.Project.Name)
|
||||||
|
if len(name) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if unicode.ToLower(name[0]) == r {
|
||||||
|
l.Select(i)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ProjectsToItems converts projects to list items.
|
// ProjectsToItems converts projects to list items.
|
||||||
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
|
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
|
||||||
items := make([]list.Item, len(projects))
|
items := make([]list.Item, len(projects))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package views
|
package views
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
@@ -46,6 +48,18 @@ func (m *StatusMenu) MoveUp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JumpToShortcut moves the cursor to the status whose label starts with r.
|
||||||
|
func (m *StatusMenu) JumpToShortcut(r rune) bool {
|
||||||
|
r = unicode.ToLower(r)
|
||||||
|
for i, st := range ticket.AllStatuses {
|
||||||
|
if st.Shortcut() == r {
|
||||||
|
m.Cursor = i
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Selected returns the currently highlighted status.
|
// Selected returns the currently highlighted status.
|
||||||
func (m StatusMenu) Selected() ticket.Status {
|
func (m StatusMenu) Selected() ticket.Status {
|
||||||
if m.Cursor < 0 || m.Cursor >= len(ticket.AllStatuses) {
|
if m.Cursor < 0 || m.Cursor >= len(ticket.AllStatuses) {
|
||||||
@@ -59,13 +73,12 @@ func (m StatusMenu) View() string {
|
|||||||
var lines []string
|
var lines []string
|
||||||
lines = append(lines, m.Styles.Title.Render("Set status"))
|
lines = append(lines, m.Styles.Title.Render("Set status"))
|
||||||
for i, st := range ticket.AllStatuses {
|
for i, st := range ticket.AllStatuses {
|
||||||
label := " " + st.Label()
|
entry := fmt.Sprintf("%c %s", st.Shortcut(), st.Label())
|
||||||
if i == m.Cursor {
|
if i == m.Cursor {
|
||||||
label = m.Styles.Selected.Render("▸ " + st.Label())
|
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
|
||||||
} else {
|
} else {
|
||||||
label = m.Styles.Normal.Render(label)
|
lines = append(lines, m.Styles.Normal.Render(" "+entry))
|
||||||
}
|
}
|
||||||
lines = append(lines, label)
|
|
||||||
}
|
}
|
||||||
body := strings.Join(lines, "\n")
|
body := strings.Join(lines, "\n")
|
||||||
w := m.Width
|
w := m.Width
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package views
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ type TicketList struct {
|
|||||||
Width int
|
Width int
|
||||||
Height int
|
Height int
|
||||||
Styles TicketListStyles
|
Styles TicketListStyles
|
||||||
|
SpentByID map[string]time.Duration // optional; shown on selected card
|
||||||
|
ChecklistCursor int // index into checkboxes of the selected ticket
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSize updates the viewport dimensions.
|
// SetSize updates the viewport dimensions.
|
||||||
@@ -47,6 +50,7 @@ func (l *TicketList) SetItems(items []ticket.Ticket) {
|
|||||||
l.Cursor = max(0, len(l.Items)-1)
|
l.Cursor = max(0, len(l.Items)-1)
|
||||||
}
|
}
|
||||||
l.Offset = 0
|
l.Offset = 0
|
||||||
|
l.clampChecklistCursor()
|
||||||
l.ensureVisible()
|
l.ensureVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +72,7 @@ func (l *TicketList) Len() int { return len(l.Items) }
|
|||||||
func (l *TicketList) MoveDown() {
|
func (l *TicketList) MoveDown() {
|
||||||
if l.Cursor < len(l.Items)-1 {
|
if l.Cursor < len(l.Items)-1 {
|
||||||
l.Cursor++
|
l.Cursor++
|
||||||
|
l.ChecklistCursor = 0
|
||||||
l.ensureVisible()
|
l.ensureVisible()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +81,7 @@ func (l *TicketList) MoveDown() {
|
|||||||
func (l *TicketList) MoveUp() {
|
func (l *TicketList) MoveUp() {
|
||||||
if l.Cursor > 0 {
|
if l.Cursor > 0 {
|
||||||
l.Cursor--
|
l.Cursor--
|
||||||
|
l.ChecklistCursor = 0
|
||||||
l.ensureVisible()
|
l.ensureVisible()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,10 +91,61 @@ func (l *TicketList) Select(idx int) {
|
|||||||
if idx < 0 || idx >= len(l.Items) {
|
if idx < 0 || idx >= len(l.Items) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if l.Cursor != idx {
|
||||||
|
l.ChecklistCursor = 0
|
||||||
|
}
|
||||||
l.Cursor = idx
|
l.Cursor = idx
|
||||||
|
l.clampChecklistCursor()
|
||||||
l.ensureVisible()
|
l.ensureVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChecklistLen returns how many task-list items the selected ticket has.
|
||||||
|
func (l *TicketList) ChecklistLen() int {
|
||||||
|
t, ok := l.Selected()
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(ticket.ListCheckboxes(t.Description))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveChecklistDown advances the checkbox cursor (wraps).
|
||||||
|
func (l *TicketList) MoveChecklistDown() {
|
||||||
|
n := l.ChecklistLen()
|
||||||
|
if n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.ChecklistCursor = (l.ChecklistCursor + 1) % n
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveChecklistUp moves the checkbox cursor up (wraps).
|
||||||
|
func (l *TicketList) MoveChecklistUp() {
|
||||||
|
n := l.ChecklistLen()
|
||||||
|
if n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.ChecklistCursor = (l.ChecklistCursor - 1 + n) % n
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChecklistCursor sets the checkbox highlight index (clamped).
|
||||||
|
func (l *TicketList) SetChecklistCursor(idx int) {
|
||||||
|
l.ChecklistCursor = idx
|
||||||
|
l.clampChecklistCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *TicketList) clampChecklistCursor() {
|
||||||
|
n := l.ChecklistLen()
|
||||||
|
if n == 0 {
|
||||||
|
l.ChecklistCursor = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if l.ChecklistCursor < 0 {
|
||||||
|
l.ChecklistCursor = 0
|
||||||
|
}
|
||||||
|
if l.ChecklistCursor >= n {
|
||||||
|
l.ChecklistCursor = n - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// View renders only the visible ticket cards.
|
// View renders only the visible ticket cards.
|
||||||
func (l *TicketList) View() string {
|
func (l *TicketList) View() string {
|
||||||
if l.Height < 1 {
|
if l.Height < 1 {
|
||||||
@@ -124,12 +181,15 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
|
|||||||
|
|
||||||
var content string
|
var content string
|
||||||
if selected {
|
if selected {
|
||||||
// Expanded: headline + glamour-rendered description (fixed dark style).
|
// Expanded: headline + optional spent + description (interactive checkboxes).
|
||||||
desc := strings.TrimSpace(t.Description)
|
|
||||||
if desc != "" {
|
|
||||||
content = headerLine + "\n" + RenderMarkdown(desc, innerWidth)
|
|
||||||
} else {
|
|
||||||
content = headerLine
|
content = headerLine
|
||||||
|
if spent, ok := l.SpentByID[t.ID]; ok && spent > 0 {
|
||||||
|
content += "\n" + lipgloss.NewStyle().Faint(true).Render(
|
||||||
|
"spent: "+ticket.FormatDurationHuman(spent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.Description) != "" {
|
||||||
|
content += "\n" + RenderDescription(t.Description, innerWidth, l.ChecklistCursor)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
content = headerLine
|
content = headerLine
|
||||||
@@ -190,8 +250,11 @@ func (l *TicketList) estimateHeight(index int) int {
|
|||||||
if index < 0 || index >= len(l.Items) {
|
if index < 0 || index >= len(l.Items) {
|
||||||
return CollapsedCardHeight
|
return CollapsedCardHeight
|
||||||
}
|
}
|
||||||
if index == l.Cursor && strings.TrimSpace(l.Items[index].Description) != "" {
|
if index == l.Cursor {
|
||||||
return lipgloss.Height(l.renderCard(l.Items[index], true))
|
t := l.Items[index]
|
||||||
|
if strings.TrimSpace(t.Description) != "" || (l.SpentByID != nil && l.SpentByID[t.ID] > 0) {
|
||||||
|
return lipgloss.Height(l.renderCard(t, true))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return CollapsedCardHeight
|
return CollapsedCardHeight
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user