Compare commits

...
10 Commits
Author SHA1 Message Date
SkrueckenandCursor 81f2902ff1 Demo interactive checklist toggles in the VHS walkthrough.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 15:08:21 +02:00
SkrueckenandCursor f08084d5e1 Add interactive Markdown checklist toggles in the ticket preview.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 14:37:14 +02:00
SkrueckenandCursor 7666715f6c Allow moving tickets between projects with P.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 14:19:10 +02:00
SkrueckenandCursor c5920ffd54 Allow deleting projects from the project picker with x/X.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 13:59:20 +02:00
SkrueckenandCursor f3a592f3ea Ship as tm with install to GOPATH/bin and sandbox env overrides.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 13:55:54 +02:00
SkrueckenandCursor 2dcab9e4d8 Demo ticket editing in vim with rendered markdown on the card.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 13:52:20 +02:00
SkrueckenandCursor 21a0d2ad51 Add a VHS tape that demos the full task-master feature set.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 11:19:52 +02:00
SkrueckenandCursor bfc1cbd4d9 Add ticket time tracking with reports and entry management.
Support start/stop timer, manual durations, grouped reports, and per-ticket edit/delete of time entries with undo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 11:05:10 +02:00
SkrueckenandCursor 41feba11de Add multiselect board filters and letter-jump on picker menus.
Persist comma-separated list filters per project, toggle with space, and jump by first letter in board and project pickers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 10:42:59 +02:00
SkrueckenandCursor aa8f89b43a Jump to status options with their first-letter shortcuts.
o/w/d select OPEN/WIP/DONE in the status menu, with the hotkeys shown inline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 10:38:28 +02:00
46 changed files with 3822 additions and 157 deletions
+4
View File
@@ -1,4 +1,8 @@
/bin/
.dev-home/
*.exe
*.test
*.out
demo.gif
demo.mp4
demo.webm
+28 -2
View File
@@ -1,10 +1,16 @@
BINARY := task-master
BINARY := tm
CMD := ./cmd/task-master
VERSION_PKG := task/internal/version
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
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:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
@@ -12,5 +18,25 @@ build:
run: build
./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
./bin/$(BINARY) version
# Record a full feature walkthrough (requires vhs: https://github.com/charmbracelet/vhs)
demo:
vhs demo.tape
+9 -4
View File
@@ -18,7 +18,7 @@ func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "version", "--version", "-v":
fmt.Printf("%s %s\n", config.AppName, version.BuildVersion)
fmt.Printf("%s %s\n", config.CliName, version.BuildVersion)
return
case "help", "--help", "-h":
printHelp()
@@ -43,16 +43,17 @@ func run() error {
}
store := ticket.NewJSONStore(cfg.DataDir)
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
projects := project.NewDirStore(cfg.DataDir)
if err := projects.Ensure(cfg.ActiveProject); err != nil {
return fmt.Errorf("ensure project: %w", err)
}
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store)
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore)
if err != nil {
return fmt.Errorf("history: %w", err)
}
model := ui.New(cfg, store, projects, history)
model := ui.New(cfg, store, projects, timeStore, history)
prog := tea.NewProgram(model, tea.WithAltScreen())
if _, err := prog.Run(); err != nil {
return fmt.Errorf("tui: %w", err)
@@ -68,5 +69,9 @@ Usage:
%s version Print build version
%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)
}
+246
View File
@@ -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
+14 -11
View File
@@ -21,12 +21,13 @@ type Executor interface {
// History is an undo/redo stack for Command values, optionally persisted to disk.
type History struct {
mu sync.Mutex
undoS []Command
redoS []Command
path string
store ticket.Store
max int
mu sync.Mutex
undoS []Command
redoS []Command
path string
store ticket.Store
timeStore ticket.TimeStore
max int
}
// NewHistory creates an empty in-memory command history (cap MaxHistory).
@@ -36,13 +37,15 @@ func NewHistory() *History {
// OpenHistory loads a persisted history from path (DataDir/history.json) or
// starts empty if the file is missing. Subsequent Do/Undo/Redo write back.
func OpenHistory(path string, store ticket.Store) (*History, error) {
// timeStore may be nil if time-entry commands are unused.
func OpenHistory(path string, store ticket.Store, timeStore ticket.TimeStore) (*History, error) {
h := &History{
path: path,
store: store,
max: MaxHistory,
path: path,
store: store,
timeStore: timeStore,
max: MaxHistory,
}
undo, redo, err := loadHistoryFile(path, store)
undo, redo, err := loadHistoryFile(path, store, timeStore)
if err != nil {
return nil, err
}
+57
View File
@@ -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)
}
}
+66 -14
View File
@@ -21,17 +21,24 @@ type persistedFile struct {
}
type persistedCmd struct {
Type string `json:"type"`
Ticket *ticket.Ticket `json:"ticket,omitempty"`
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
Before *ticket.Ticket `json:"before,omitempty"`
After *ticket.Ticket `json:"after,omitempty"`
Type string `json:"type"`
Ticket *ticket.Ticket `json:"ticket,omitempty"`
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
Before *ticket.Ticket `json:"before,omitempty"`
After *ticket.Ticket `json:"after,omitempty"`
TimeEntry *ticket.TimeEntry `json:"timeEntry,omitempty"`
TimeBefore *ticket.TimeEntry `json:"timeBefore,omitempty"`
TimeAfter *ticket.TimeEntry `json:"timeAfter,omitempty"`
}
const (
typeCreate = "create"
typeDelete = "delete"
typeUpdate = "update"
typeCreate = "create"
typeDelete = "delete"
typeUpdate = "update"
typeAddTime = "add_time"
typeRemoveTime = "remove_time"
typeUpdateTime = "update_time"
typeMove = "move"
)
// HistoryPath returns DataDir/history.json.
@@ -54,12 +61,28 @@ func encodeCmd(cmd Command) (persistedCmd, error) {
case *UpdateTicket:
b, a := c.Before, c.After
return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil
case *AddTimeEntry:
e := c.Entry
if c.created.ID != "" {
e = c.created
}
cp := e
return persistedCmd{Type: typeAddTime, TimeEntry: &cp}, nil
case *RemoveTimeEntry:
cp := c.Snapshot
return persistedCmd{Type: typeRemoveTime, TimeEntry: &cp}, nil
case *UpdateTimeEntry:
b, a := c.Before, c.After
return persistedCmd{Type: typeUpdateTime, TimeBefore: &b, TimeAfter: &a}, nil
case *MoveTicket:
b, a := c.Before, c.After
return persistedCmd{Type: typeMove, Before: &b, After: &a}, nil
default:
return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd)
}
}
func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
func decodeCmd(rec persistedCmd, store ticket.Store, timeStore ticket.TimeStore) (Command, error) {
switch rec.Type {
case typeCreate:
if rec.Ticket == nil {
@@ -76,6 +99,35 @@ func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
return nil, fmt.Errorf("update record missing before/after")
}
return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil
case typeAddTime:
if rec.TimeEntry == nil {
return nil, fmt.Errorf("add_time record missing entry")
}
if timeStore == nil {
return nil, fmt.Errorf("add_time: time store not configured")
}
return &AddTimeEntry{Store: timeStore, Entry: *rec.TimeEntry, created: *rec.TimeEntry}, nil
case typeRemoveTime:
if rec.TimeEntry == nil {
return nil, fmt.Errorf("remove_time record missing entry")
}
if timeStore == nil {
return nil, fmt.Errorf("remove_time: time store not configured")
}
return &RemoveTimeEntry{Store: timeStore, Snapshot: *rec.TimeEntry}, nil
case typeUpdateTime:
if rec.TimeBefore == nil || rec.TimeAfter == nil {
return nil, fmt.Errorf("update_time record missing before/after")
}
if timeStore == nil {
return nil, fmt.Errorf("update_time: time store not configured")
}
return &UpdateTimeEntry{Store: timeStore, Before: *rec.TimeBefore, After: *rec.TimeAfter}, nil
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:
return nil, fmt.Errorf("unknown command type %q", rec.Type)
}
@@ -93,10 +145,10 @@ func encodeStack(cmds []Command) ([]persistedCmd, error) {
return out, nil
}
func decodeStack(recs []persistedCmd, store ticket.Store) ([]Command, error) {
func decodeStack(recs []persistedCmd, store ticket.Store, timeStore ticket.TimeStore) ([]Command, error) {
out := make([]Command, 0, len(recs))
for _, rec := range recs {
cmd, err := decodeCmd(rec, store)
cmd, err := decodeCmd(rec, store, timeStore)
if err != nil {
return nil, err
}
@@ -154,7 +206,7 @@ func (h *History) persistLocked() error {
return nil
}
func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err error) {
func loadHistoryFile(path string, store ticket.Store, timeStore ticket.TimeStore) (undo, redo []Command, err error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
@@ -169,11 +221,11 @@ func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err
if err := json.Unmarshal(data, &file); err != nil {
return nil, nil, fmt.Errorf("parse history: %w", err)
}
undo, err = decodeStack(file.Undo, store)
undo, err = decodeStack(file.Undo, store, timeStore)
if err != nil {
return nil, nil, err
}
redo, err = decodeStack(file.Redo, store)
redo, err = decodeStack(file.Redo, store, timeStore)
if err != nil {
return nil, nil, err
}
+3 -3
View File
@@ -14,7 +14,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
store := ticket.NewJSONStore(dir)
path := command.HistoryPath(dir)
h1, err := command.OpenHistory(path, store)
h1, err := command.OpenHistory(path, store, nil)
if err != nil {
t.Fatal(err)
}
@@ -30,7 +30,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
t.Fatalf("history file missing: %v", err)
}
h2, err := command.OpenHistory(path, store)
h2, err := command.OpenHistory(path, store, nil)
if err != nil {
t.Fatal(err)
}
@@ -49,7 +49,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
func TestHistoryCapsAtMax(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONStore(dir)
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store)
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, nil)
if err != nil {
t.Fatal(err)
}
+42 -2
View File
@@ -9,8 +9,8 @@ import (
// CreateTicket creates a ticket; Undo deletes it.
type CreateTicket struct {
Store ticket.Store
Ticket ticket.Ticket
Store ticket.Store
Ticket ticket.Ticket
created ticket.Ticket
}
@@ -79,3 +79,43 @@ func (c *UpdateTicket) Undo(ctx context.Context) error {
_ = ctx
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
}
+81
View File
@@ -0,0 +1,81 @@
package command
import (
"context"
"fmt"
"task/internal/ticket"
)
// AddTimeEntry creates a time entry; Undo deletes it.
type AddTimeEntry struct {
Store ticket.TimeStore
Entry ticket.TimeEntry
created ticket.TimeEntry
}
func (c *AddTimeEntry) Name() string { return "add time entry" }
func (c *AddTimeEntry) Execute(ctx context.Context) error {
_ = ctx
created, err := c.Store.Create(c.Entry)
if err != nil {
return err
}
c.created = created
c.Entry = created
return nil
}
func (c *AddTimeEntry) Undo(ctx context.Context) error {
_ = ctx
id := c.created.ID
if id == "" {
id = c.Entry.ID
}
if id == "" {
return fmt.Errorf("add time undo: missing entry id")
}
return c.Store.Delete(id)
}
// RemoveTimeEntry deletes a time entry; Undo restores the snapshot.
type RemoveTimeEntry struct {
Store ticket.TimeStore
Snapshot ticket.TimeEntry
}
func (c *RemoveTimeEntry) Name() string { return "remove time entry" }
func (c *RemoveTimeEntry) Execute(ctx context.Context) error {
_ = ctx
if c.Snapshot.ID == "" {
return fmt.Errorf("remove time: missing entry id")
}
return c.Store.Delete(c.Snapshot.ID)
}
func (c *RemoveTimeEntry) Undo(ctx context.Context) error {
_ = ctx
_, err := c.Store.Create(c.Snapshot)
return err
}
// UpdateTimeEntry applies a new entry state; Undo restores Before.
type UpdateTimeEntry struct {
Store ticket.TimeStore
Before ticket.TimeEntry
After ticket.TimeEntry
}
func (c *UpdateTimeEntry) Name() string { return "update time entry" }
func (c *UpdateTimeEntry) Execute(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.After)
}
func (c *UpdateTimeEntry) Undo(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.Before)
}
+75
View File
@@ -0,0 +1,75 @@
package command_test
import (
"testing"
"time"
"task/internal/command"
"task/internal/ticket"
)
func TestUpdateTimeEntryUndo(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONTimeStore(dir)
h := command.NewHistory()
created, err := store.Create(ticket.TimeEntry{
TicketID: "t1",
ProjectID: "inbox",
Start: time.Now().UTC().Add(-time.Hour),
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
after := created
after.Duration = 30 * time.Minute
after.End = after.Start.Add(after.Duration)
if err := h.Do(&command.UpdateTimeEntry{Store: store, Before: created, After: after}); err != nil {
t.Fatal(err)
}
list, _ := store.ListByTicket("t1")
if len(list) != 1 || list[0].Duration != 30*time.Minute {
t.Fatalf("after update: %+v", list)
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, _ = store.ListByTicket("t1")
if len(list) != 1 || list[0].Duration != time.Hour {
t.Fatalf("after undo: %+v", list)
}
}
func TestRemoveTimeEntryUndo(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONTimeStore(dir)
h := command.NewHistory()
created, err := store.Create(ticket.TimeEntry{
TicketID: "t1",
ProjectID: "inbox",
Start: time.Now().UTC(),
Duration: 15 * time.Minute,
Source: ticket.SourceTimer,
})
if err != nil {
t.Fatal(err)
}
if err := h.Do(&command.RemoveTimeEntry{Store: store, Snapshot: created}); err != nil {
t.Fatal(err)
}
list, _ := store.List()
if len(list) != 0 {
t.Fatalf("expected empty after remove, got %d", len(list))
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, _ = store.List()
if len(list) != 1 || list[0].ID != created.ID {
t.Fatalf("after undo: %+v", list)
}
}
+28 -13
View File
@@ -3,18 +3,25 @@ package config
import "path/filepath"
const (
AppName = "task-master"
DefaultProject = "inbox"
DefaultTheme = "default"
configFileName = "config.json"
// AppName is used for config/data directory names under XDG paths.
AppName = "task-master"
// CliName is the installed binary / command name.
CliName = "tm"
DefaultProject = "inbox"
DefaultTheme = "default"
configFileName = "config.json"
envHome = "TASK_MASTER_HOME"
envDataDir = "TASK_MASTER_DATA_DIR"
)
// Config holds application settings persisted under ~/.config/task-master.
type Config struct {
ActiveProject string `json:"activeProject"`
DataDir string `json:"dataDir"`
Theme string `json:"theme"`
// ProjectLists maps project ID → board list id (all|backlog|in_progress|done).
ActiveProject string `json:"activeProject"`
DataDir string `json:"dataDir"`
Theme string `json:"theme"`
// ProjectLists maps project ID → comma-separated board list ids
// (e.g. "all" or "backlog,in_progress").
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).
func (c *Config) ActiveBoardList(projectID string) string {
// ActiveBoardLists returns the persisted board filter string for a project (default: "all").
func (c *Config) ActiveBoardLists(projectID string) string {
if c.ProjectLists == nil {
return "all"
}
@@ -66,10 +73,18 @@ func (c *Config) ActiveBoardList(projectID string) string {
return "all"
}
// SetActiveBoardList stores the board list for a project.
func (c *Config) SetActiveBoardList(projectID, listID string) {
// SetActiveBoardLists stores the board filter string for a project.
func (c *Config) SetActiveBoardLists(projectID, listIDs string) {
if c.ProjectLists == nil {
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)
}
+24 -5
View File
@@ -5,14 +5,28 @@ import (
"fmt"
"os"
"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.
// It also ensures DataDir and the active project directory exist.
func LoadOrCreate() (*Config, error) {
home, err := os.UserHomeDir()
home, err := ResolveHome()
if err != nil {
return nil, fmt.Errorf("resolve home directory: %w", err)
return nil, err
}
cfgPath := Path(home)
@@ -26,6 +40,10 @@ func LoadOrCreate() (*Config, error) {
return nil, err
}
if d := strings.TrimSpace(os.Getenv(envDataDir)); d != "" {
cfg.DataDir = filepath.Clean(d)
}
if err := ensureDataDirs(cfg); err != nil {
return nil, err
}
@@ -54,11 +72,12 @@ func readOrCreate(cfgPath, home string) (*Config, error) {
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 {
home, err := os.UserHomeDir()
home, err := ResolveHome()
if err != nil {
return fmt.Errorf("resolve home directory: %w", err)
return err
}
cfgPath := Path(home)
+39
View File
@@ -30,6 +30,8 @@ func TestDefaultsAndApplyDefaults(t *testing.T) {
func TestSaveRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("TASK_MASTER_HOME", "")
t.Setenv("TASK_MASTER_DATA_DIR", "")
cfg := config.Defaults(home)
cfg.ActiveProject = "work"
@@ -54,6 +56,8 @@ func TestSaveRoundTrip(t *testing.T) {
func TestLoadOrCreate(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("TASK_MASTER_HOME", "")
t.Setenv("TASK_MASTER_DATA_DIR", "")
cfg, err := config.LoadOrCreate()
if err != nil {
@@ -81,3 +85,38 @@ func TestLoadOrCreate(t *testing.T) {
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)
}
}
+23
View File
@@ -11,6 +11,7 @@ import (
type Store interface {
List() ([]Project, error)
Ensure(id string) error
Delete(id string) error
}
// DirStore discovers projects as subdirectories of dataDir/projects.
@@ -63,3 +64,25 @@ func (s *DirStore) Ensure(id string) error {
}
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
}
+46
View File
@@ -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")
}
}
+122
View File
@@ -1,5 +1,10 @@
package ticket
import (
"strings"
"unicode"
)
// BoardList is a project-scoped view/filter over tickets by status.
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.
func (b BoardList) Index() int {
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.
func (b BoardList) Matches(status Status) bool {
switch b {
+35
View File
@@ -41,3 +41,38 @@ func TestBoardListDefaultStatus(t *testing.T) {
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")
}
}
+63
View File
@@ -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
}
+65
View File
@@ -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)
}
}
+59
View File
@@ -0,0 +1,59 @@
package ticket
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
var (
reHourMin = regexp.MustCompile(`(?i)^(\d+)\s*h(?:ours?)?\s*(\d+)\s*m(?:ins?)?$`)
reHourOnly = regexp.MustCompile(`(?i)^(\d+(?:\.\d+)?)\s*h(?:ours?)?$`)
reMinOnly = regexp.MustCompile(`(?i)^(\d+)\s*m(?:ins?)?$`)
reSecOnly = regexp.MustCompile(`(?i)^(\d+)\s*s(?:ecs?)?$`)
reColon = regexp.MustCompile(`^(\d+):(\d{1,2})$`)
)
// ParseDurationFlexible parses user durations like 1h30m, 90m, 1.5h, 1:30.
func ParseDurationFlexible(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("duration required")
}
// Prefer Go's parser when it works (e.g. 1h30m, 90m).
if d, err := time.ParseDuration(strings.ToLower(s)); err == nil && d > 0 {
return d, nil
}
if m := reHourMin.FindStringSubmatch(s); m != nil {
h, _ := strconv.Atoi(m[1])
min, _ := strconv.Atoi(m[2])
return time.Duration(h)*time.Hour + time.Duration(min)*time.Minute, nil
}
if m := reHourOnly.FindStringSubmatch(s); m != nil {
h, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0, fmt.Errorf("invalid duration %q", s)
}
return time.Duration(h * float64(time.Hour)), nil
}
if m := reMinOnly.FindStringSubmatch(s); m != nil {
min, _ := strconv.Atoi(m[1])
return time.Duration(min) * time.Minute, nil
}
if m := reSecOnly.FindStringSubmatch(s); m != nil {
sec, _ := strconv.Atoi(m[1])
return time.Duration(sec) * time.Second, nil
}
if m := reColon.FindStringSubmatch(s); m != nil {
h, _ := strconv.Atoi(m[1])
min, _ := strconv.Atoi(m[2])
if min >= 60 {
return 0, fmt.Errorf("invalid minutes in %q", s)
}
return time.Duration(h)*time.Hour + time.Duration(min)*time.Minute, nil
}
return 0, fmt.Errorf("invalid duration %q (try 1h30m, 90m, 1.5h)", s)
}
+58
View File
@@ -0,0 +1,58 @@
package ticket_test
import (
"testing"
"time"
"task/internal/ticket"
)
func TestParseDurationFlexible(t *testing.T) {
cases := []struct {
in string
want time.Duration
}{
{"1h30m", 90 * time.Minute},
{"90m", 90 * time.Minute},
{"1.5h", 90 * time.Minute},
{"2h", 2 * time.Hour},
{"45m", 45 * time.Minute},
{"30s", 30 * time.Second},
{"1:30", 90 * time.Minute},
{" 1h ", time.Hour},
}
for _, tc := range cases {
got, err := ticket.ParseDurationFlexible(tc.in)
if err != nil {
t.Fatalf("%q: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("%q: got %v want %v", tc.in, got, tc.want)
}
}
}
func TestParseDurationFlexibleRejects(t *testing.T) {
for _, in := range []string{"", "abc", "1:99", "-5m"} {
if _, err := ticket.ParseDurationFlexible(in); err == nil {
t.Fatalf("%q: expected error", in)
}
}
}
func TestFormatDurationHuman(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{2*time.Hour + 15*time.Minute, "2h15m"},
{3 * time.Hour, "3h"},
{45 * time.Minute, "45m"},
{30 * time.Second, "30s"},
}
for _, tc := range cases {
if got := ticket.FormatDurationHuman(tc.d); got != tc.want {
t.Fatalf("%v: got %q want %q", tc.d, got, tc.want)
}
}
}
+67
View File
@@ -146,6 +146,73 @@ func (s *JSONStore) Update(t Ticket) error {
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.
func (s *JSONStore) Delete(id string) error {
s.mu.Lock()
+30
View File
@@ -97,3 +97,33 @@ func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
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)
}
}
+332
View File
@@ -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
}
+128
View File
@@ -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)
}
}
}
}
+157
View File
@@ -0,0 +1,157 @@
package ticket
import (
"fmt"
"sort"
"time"
)
// GroupBy selects how Aggregate buckets time entries.
type GroupBy string
const (
GroupByProject GroupBy = "project"
GroupByStatus GroupBy = "status"
GroupByTicket GroupBy = "ticket"
GroupByDay GroupBy = "day"
)
// AllGroupBys is the ordered list of report grouping options.
var AllGroupBys = []GroupBy{
GroupByProject,
GroupByStatus,
GroupByTicket,
GroupByDay,
}
// Label returns a human-readable name for the grouping.
func (g GroupBy) Label() string {
switch g {
case GroupByProject:
return "Project"
case GroupByStatus:
return "Status"
case GroupByTicket:
return "Ticket"
case GroupByDay:
return "Day"
default:
return string(g)
}
}
// Shortcut returns the jump letter for the grouping picker.
func (g GroupBy) Shortcut() rune {
switch g {
case GroupByProject:
return 'p'
case GroupByStatus:
return 's'
case GroupByTicket:
return 't'
case GroupByDay:
return 'd'
default:
return 0
}
}
// Index returns the position in AllGroupBys, or 0 if unknown.
func (g GroupBy) Index() int {
for i, x := range AllGroupBys {
if x == g {
return i
}
}
return 0
}
// GroupRow is one aggregated bucket in a time report.
type GroupRow struct {
Key string
Label string
Duration time.Duration
}
// Aggregate sums entry durations grouped by groupBy.
// ticketsByID is used for status/ticket labels; missing tickets fall back to IDs.
func Aggregate(entries []TimeEntry, ticketsByID map[string]Ticket, groupBy GroupBy) []GroupRow {
sums := map[string]time.Duration{}
labels := map[string]string{}
for _, e := range entries {
key, label := groupKeyLabel(e, ticketsByID, groupBy)
sums[key] += e.Duration
if _, ok := labels[key]; !ok {
labels[key] = label
}
}
rows := make([]GroupRow, 0, len(sums))
for key, d := range sums {
rows = append(rows, GroupRow{Key: key, Label: labels[key], Duration: d})
}
sort.Slice(rows, func(i, j int) bool {
if groupBy == GroupByDay {
return rows[i].Key > rows[j].Key // newest day first
}
if rows[i].Duration != rows[j].Duration {
return rows[i].Duration > rows[j].Duration
}
return rows[i].Label < rows[j].Label
})
return rows
}
func groupKeyLabel(e TimeEntry, ticketsByID map[string]Ticket, groupBy GroupBy) (key, label string) {
t, ok := ticketsByID[e.TicketID]
switch groupBy {
case GroupByProject:
key = e.ProjectID
if key == "" && ok {
key = t.ProjectID
}
if key == "" {
key = "(none)"
}
return key, key
case GroupByStatus:
if ok {
key = string(t.Status)
} else {
key = "(unknown)"
}
return key, key
case GroupByTicket:
key = e.TicketID
if ok && t.Title != "" {
return key, t.Title
}
if key == "" {
return "(none)", "(none)"
}
return key, key
case GroupByDay:
start := e.Start
if start.IsZero() {
start = e.End
}
if start.IsZero() {
return "(unknown)", "(unknown)"
}
local := start.Local()
key = local.Format("2006-01-02")
return key, key
default:
return fmt.Sprintf("%v", groupBy), string(groupBy)
}
}
// TotalDuration sums all entry durations.
func TotalDuration(entries []TimeEntry) time.Duration {
var sum time.Duration
for _, e := range entries {
sum += e.Duration
}
return sum
}
+72
View File
@@ -0,0 +1,72 @@
package ticket_test
import (
"testing"
"time"
"task/internal/ticket"
)
func TestAggregateByProject(t *testing.T) {
entries := []ticket.TimeEntry{
{TicketID: "a", ProjectID: "p1", Duration: time.Hour},
{TicketID: "b", ProjectID: "p1", Duration: 30 * time.Minute},
{TicketID: "c", ProjectID: "p2", Duration: 15 * time.Minute},
}
rows := ticket.Aggregate(entries, nil, ticket.GroupByProject)
if len(rows) != 2 {
t.Fatalf("rows=%d want 2", len(rows))
}
if rows[0].Key != "p1" || rows[0].Duration != 90*time.Minute {
t.Fatalf("first row: %+v", rows[0])
}
if ticket.TotalDuration(entries) != 105*time.Minute {
t.Fatalf("total=%v", ticket.TotalDuration(entries))
}
}
func TestAggregateByStatusAndTicket(t *testing.T) {
tickets := map[string]ticket.Ticket{
"a": {ID: "a", Title: "Alpha", Status: ticket.StatusTodo},
"b": {ID: "b", Title: "Beta", Status: ticket.StatusDone},
}
entries := []ticket.TimeEntry{
{TicketID: "a", Duration: time.Hour},
{TicketID: "b", Duration: 20 * time.Minute},
{TicketID: "a", Duration: 10 * time.Minute},
}
byStatus := ticket.Aggregate(entries, tickets, ticket.GroupByStatus)
if len(byStatus) != 2 {
t.Fatalf("status rows=%d", len(byStatus))
}
byTicket := ticket.Aggregate(entries, tickets, ticket.GroupByTicket)
if len(byTicket) != 2 {
t.Fatalf("ticket rows=%d", len(byTicket))
}
if byTicket[0].Label != "Alpha" || byTicket[0].Duration != 70*time.Minute {
t.Fatalf("top ticket: %+v", byTicket[0])
}
}
func TestAggregateByDay(t *testing.T) {
d1 := time.Date(2026, 10, 18, 10, 0, 0, 0, time.Local)
d2 := time.Date(2026, 10, 19, 11, 0, 0, 0, time.Local)
entries := []ticket.TimeEntry{
{Start: d1, Duration: time.Hour},
{Start: d2, Duration: 30 * time.Minute},
{Start: d1, Duration: 15 * time.Minute},
}
rows := ticket.Aggregate(entries, nil, ticket.GroupByDay)
if len(rows) != 2 {
t.Fatalf("day rows=%d", len(rows))
}
// Newest day first.
if rows[0].Key != "2026-10-19" {
t.Fatalf("first day key=%q", rows[0].Key)
}
if rows[1].Duration != 75*time.Minute {
t.Fatalf("older day dur=%v", rows[1].Duration)
}
}
+1
View File
@@ -7,5 +7,6 @@ type Store interface {
Get(id string) (Ticket, error)
Create(t Ticket) (Ticket, error)
Update(t Ticket) error
Move(id, toProjectID string) (Ticket, error)
Delete(id string) error
}
+13 -1
View File
@@ -1,6 +1,9 @@
package ticket
import "time"
import (
"time"
"unicode"
)
// Status represents the workflow state of a ticket.
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.
func (s Status) Index() int {
for i, st := range AllStatuses {
+105
View File
@@ -0,0 +1,105 @@
package ticket
import (
"encoding/json"
"fmt"
"time"
)
const (
SourceTimer = "timer"
SourceManual = "manual"
)
// TimeEntry is a recorded block of work on a ticket.
type TimeEntry struct {
ID string `json:"id"`
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Duration time.Duration `json:"duration"`
Source string `json:"source"`
Note string `json:"note,omitempty"`
}
// MarshalJSON encodes Duration as nanoseconds for stable JSON.
func (e TimeEntry) MarshalJSON() ([]byte, error) {
type alias struct {
ID string `json:"id"`
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Duration int64 `json:"duration"`
Source string `json:"source"`
Note string `json:"note,omitempty"`
}
return json.Marshal(alias{
ID: e.ID,
TicketID: e.TicketID,
ProjectID: e.ProjectID,
Start: e.Start,
End: e.End,
Duration: int64(e.Duration),
Source: e.Source,
Note: e.Note,
})
}
// UnmarshalJSON decodes Duration from nanoseconds.
func (e *TimeEntry) UnmarshalJSON(data []byte) error {
type alias struct {
ID string `json:"id"`
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Duration int64 `json:"duration"`
Source string `json:"source"`
Note string `json:"note,omitempty"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
e.ID = a.ID
e.TicketID = a.TicketID
e.ProjectID = a.ProjectID
e.Start = a.Start
e.End = a.End
e.Duration = time.Duration(a.Duration)
e.Source = a.Source
e.Note = a.Note
return nil
}
// ActiveTimer is a running stopwatch persisted separately until stop.
type ActiveTimer struct {
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
StartedAt time.Time `json:"startedAt"`
}
// FormatDurationHuman returns a compact duration like 2h15m or 45m.
func FormatDurationHuman(d time.Duration) string {
if d < 0 {
d = 0
}
d = d.Round(time.Second)
h := d / time.Hour
m := (d % time.Hour) / time.Minute
s := (d % time.Minute) / time.Second
switch {
case h > 0 && m > 0:
return fmt.Sprintf("%dh%dm", h, m)
case h > 0:
return fmt.Sprintf("%dh", h)
case m > 0 && s > 0 && m < 5:
return fmt.Sprintf("%dm%ds", m, s)
case m > 0:
return fmt.Sprintf("%dm", m)
default:
return fmt.Sprintf("%ds", s)
}
}
+18
View File
@@ -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
}
+861 -45
View File
File diff suppressed because it is too large Load Diff
+52 -1
View File
@@ -30,19 +30,32 @@ func (m Model) helpEntries() []helpEntry {
case ModeProjects:
return []helpEntry{
{"j/k", "navigate"},
{"a-z", "jump"},
{"enter", "select"},
{"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"},
}
case ModeStatus:
return []helpEntry{
{"j/k", "select"},
{"o/w/d", "jump"},
{"enter", "apply"},
{"esc", "cancel"},
}
case ModeBoard:
return []helpEntry{
{"j/k", "select"},
{"j/k", "navigate"},
{"a/b/i/d", "jump"},
{"space", "toggle"},
{"enter", "apply"},
{"esc", "cancel"},
}
@@ -51,14 +64,52 @@ func (m Model) helpEntries() []helpEntry {
{"y/enter", "confirm"},
{"esc", "cancel"},
}
case ModeTimeManual:
return []helpEntry{
{"enter", "save"},
{"esc", "cancel"},
}
case ModeTimeEntries:
return []helpEntry{
{"j/k", "navigate"},
{"e", "edit"},
{"x", "delete"},
{"u/U", "undo/redo"},
{"esc", "back"},
}
case ModeTimeEdit:
return []helpEntry{
{"enter", "save"},
{"esc", "cancel"},
}
case ModeReport:
return []helpEntry{
{"g", "group"},
{"j/k", "scroll"},
{"esc", "back"},
}
case ModeReportGroup:
return []helpEntry{
{"j/k", "select"},
{"p/s/t/d", "jump"},
{"enter", "apply"},
{"esc", "cancel"},
}
default:
return []helpEntry{
{"j/k", "navigate"},
{"J/K", "checklist"},
{"S-↑/↓", "checklist"},
{"space", "check"},
{"n", "new"},
{"e", "edit"},
{"t/T", "timer/log"},
{"m", "entries"},
{"r", "report"},
{"u/U", "undo/redo"},
{"/", "search"},
{"p", "project"},
{"P", "move"},
{"l", "list"},
{"s", "status"},
{"x", "delete"},
+53 -13
View File
@@ -4,18 +4,26 @@ import "github.com/charmbracelet/bubbles/key"
// KeyMap defines vim-style bindings for the TUI.
type KeyMap struct {
Up key.Binding
Down key.Binding
New key.Binding
Search key.Binding
Project key.Binding
Board key.Binding
Status key.Binding
Edit key.Binding
EditInEditor key.Binding // ctrl+e — open $EDITOR while creating
Delete key.Binding // x — confirm first
DeleteForce key.Binding // X — immediate
ConfirmYes key.Binding
Up key.Binding
Down key.Binding
New key.Binding
Search key.Binding
Project key.Binding
MoveTicket key.Binding // P — move selected ticket to another project
Board key.Binding
Status key.Binding
Edit key.Binding
EditInEditor key.Binding // ctrl+e — open $EDITOR while creating
Delete key.Binding // x — confirm first
DeleteForce key.Binding // X — immediate
ConfirmYes key.Binding
Toggle key.Binding // space — toggle multi-select / 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
Redo key.Binding
Quit key.Binding
@@ -46,6 +54,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("p"),
key.WithHelp("p", "project"),
),
MoveTicket: key.NewBinding(
key.WithKeys("P"),
key.WithHelp("P", "move ticket"),
),
Board: key.NewBinding(
key.WithKeys("l", "tab"),
key.WithHelp("l", "list"),
@@ -74,6 +86,34 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("y", "Y"),
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(
key.WithKeys("u", "ctrl+z"),
key.WithHelp("u", "undo"),
@@ -99,7 +139,7 @@ func DefaultKeyMap() KeyMap {
// ShortHelp implements help.KeyMap.
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.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.
+39 -5
View File
@@ -10,12 +10,15 @@ import (
)
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))
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()))
parts := []string{brand, project, board, count}
if label := m.timerHeaderLabel(); label != "" {
parts = append(parts, m.styles.FilterHint.Render(label))
}
if m.filter != "" {
parts = append(parts, m.styles.FilterHint.Render(fmt.Sprintf("filter: %q", m.filter)))
}
@@ -51,7 +54,29 @@ func (m Model) renderBody(height int) string {
content := prompt + "\n" + m.input.View()
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)
return m.projectList.View()
@@ -66,9 +91,18 @@ func (m Model) renderBody(height int) string {
case ModeConfirmDelete:
title := m.pendingDeleteTitle
if title == "" {
title = "this ticket"
if m.pendingDeleteProject != "" {
title = "this project"
} else {
title = "this ticket"
}
}
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))
}
prompt := m.styles.ErrorMsg.Render(fmt.Sprintf("Delete %q?", title))
hint := m.styles.StatusMsg.Render("y/enter confirm · esc cancel")
content := prompt + "\n" + hint
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
+75 -20
View File
@@ -1,24 +1,40 @@
package views
import (
"fmt"
"strings"
"unicode"
"task/internal/ticket"
)
// BoardMenu renders a board-list picker overlay.
// BoardMenu renders a multi-select board-list picker overlay.
type BoardMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
Cursor int
Width int
Styles StatusMenuStyles
selected map[ticket.BoardList]bool
}
// NewBoardMenu creates a menu with the given board list pre-selected.
func NewBoardMenu(current ticket.BoardList, width int, styles StatusMenuStyles) BoardMenu {
// NewBoardMenu creates a menu with the given filter pre-selected.
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{
Cursor: current.Index(),
Width: width,
Styles: styles,
Cursor: cursor,
Width: width,
Styles: styles,
selected: sel,
}
}
@@ -36,31 +52,70 @@ func (m *BoardMenu) MoveUp() {
}
}
// Selected returns the currently highlighted board list.
func (m BoardMenu) Selected() ticket.BoardList {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllBoardLists) {
return ticket.BoardAll
// JumpToShortcut moves the cursor to the list whose label starts with r.
func (m *BoardMenu) JumpToShortcut(r rune) bool {
r = unicode.ToLower(r)
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.
func (m BoardMenu) View() 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 {
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 {
label = m.Styles.Selected.Render("▸ " + bl.Label())
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
} else {
label = m.Styles.Normal.Render(label)
lines = append(lines, m.Styles.Normal.Render(" "+entry))
}
lines = append(lines, label)
}
body := strings.Join(lines, "\n")
w := m.Width
if 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)
}
+9
View File
@@ -12,3 +12,12 @@ func NewCreateInput() textinput.Model {
ti.Width = 60
return ti
}
// NewTimeManualInput returns a text input for manual duration entry.
func NewTimeManualInput() textinput.Model {
ti := textinput.New()
ti.Placeholder = "Duration (1h30m, 90m, 1.5h)…"
ti.CharLimit = 32
ti.Width = 60
return ti
}
+79
View File
@@ -0,0 +1,79 @@
package views
import (
"fmt"
"strings"
"unicode"
"task/internal/ticket"
)
// GroupByMenu renders a report grouping picker.
type GroupByMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
}
// NewGroupByMenu creates a menu with the given grouping pre-selected.
func NewGroupByMenu(current ticket.GroupBy, width int, styles StatusMenuStyles) GroupByMenu {
return GroupByMenu{
Cursor: current.Index(),
Width: width,
Styles: styles,
}
}
// MoveDown moves the selection down.
func (m *GroupByMenu) MoveDown() {
if m.Cursor < len(ticket.AllGroupBys)-1 {
m.Cursor++
}
}
// MoveUp moves the selection up.
func (m *GroupByMenu) MoveUp() {
if m.Cursor > 0 {
m.Cursor--
}
}
// JumpToShortcut moves the cursor to the option whose shortcut matches r.
func (m *GroupByMenu) JumpToShortcut(r rune) bool {
r = unicode.ToLower(r)
for i, g := range ticket.AllGroupBys {
if g.Shortcut() == r {
m.Cursor = i
return true
}
}
return false
}
// Selected returns the currently highlighted grouping.
func (m GroupByMenu) Selected() ticket.GroupBy {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllGroupBys) {
return ticket.GroupByProject
}
return ticket.AllGroupBys[m.Cursor]
}
// View renders the grouping menu.
func (m GroupByMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("Group report by"))
for i, g := range ticket.AllGroupBys {
entry := fmt.Sprintf("%c %s", g.Shortcut(), g.Label())
if i == m.Cursor {
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
} else {
lines = append(lines, m.Styles.Normal.Render(" "+entry))
}
}
body := strings.Join(lines, "\n")
w := m.Width
if w < 20 {
w = 20
}
return m.Styles.Box.Width(min(40, w-2)).Render(body)
}
+76
View File
@@ -1,11 +1,15 @@
package views
import (
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"task/internal/ticket"
)
type mdCacheKey struct {
@@ -79,6 +83,78 @@ func getRenderer(width int) (*glamour.TermRenderer, error) {
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).
func ClearMarkdownCache() {
mdMu.Lock()
+11
View File
@@ -30,3 +30,14 @@ func TestRenderMarkdownFastWithoutAutoStyle(t *testing.T) {
}
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)
}
}
+29
View File
@@ -1,6 +1,8 @@
package views
import (
"unicode"
"github.com/charmbracelet/bubbles/list"
"task/internal/project"
@@ -46,6 +48,33 @@ func NewProjectList(projects []project.Project, activeID string, width, height i
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.
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
items := make([]list.Item, len(projects))
+153
View File
@@ -0,0 +1,153 @@
package views
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// ReportStyles holds styles for the report dialog.
type ReportStyles struct {
Title lipgloss.Style
Group lipgloss.Style
Duration lipgloss.Style
Total lipgloss.Style
Hint lipgloss.Style
Selected lipgloss.Style
}
// ReportView renders aggregated time entries.
type ReportView struct {
Rows []ticket.GroupRow
Total time.Duration
GroupBy ticket.GroupBy
Offset int
Cursor int
Width int
Height int
Styles ReportStyles
}
// NewReportView builds a report view from aggregated rows.
func NewReportView(rows []ticket.GroupRow, total time.Duration, groupBy ticket.GroupBy, width, height int, styles ReportStyles) ReportView {
return ReportView{
Rows: rows,
Total: total,
GroupBy: groupBy,
Width: width,
Height: height,
Styles: styles,
}
}
// SetSize updates viewport size.
func (r *ReportView) SetSize(width, height int) {
r.Width = width
r.Height = height
r.ensureVisible()
}
// MoveDown moves selection down.
func (r *ReportView) MoveDown() {
if r.Cursor < len(r.Rows)-1 {
r.Cursor++
r.ensureVisible()
}
}
// MoveUp moves selection up.
func (r *ReportView) MoveUp() {
if r.Cursor > 0 {
r.Cursor--
r.ensureVisible()
}
}
// View renders the report.
func (r ReportView) View() string {
title := r.Styles.Title.Render(fmt.Sprintf("Time report · by %s", r.GroupBy.Label()))
hint := r.Styles.Hint.Render("g group · j/k scroll · esc back")
innerW := max(20, r.Width-2)
var lines []string
lines = append(lines, title, "")
visibleH := r.Height - 4 // title, blank, total, hint
if visibleH < 1 {
visibleH = 1
}
if len(r.Rows) == 0 {
lines = append(lines, r.Styles.Hint.Render("No time entries yet"))
} else {
end := r.Offset + visibleH
if end > len(r.Rows) {
end = len(r.Rows)
}
for i := r.Offset; i < end; i++ {
row := r.Rows[i]
label := truncateOneLine(row.Label, max(8, innerW-14))
dur := ticket.FormatDurationHuman(row.Duration)
dots := dotsPad(label, dur, innerW)
line := label + dots + dur
if i == r.Cursor {
line = r.Styles.Selected.Render(line)
} else {
line = r.Styles.Group.Render(label) + dots + r.Styles.Duration.Render(dur)
}
lines = append(lines, line)
}
}
for len(lines) < visibleH+2 {
lines = append(lines, "")
}
totalLabel := "Total"
totalDur := ticket.FormatDurationHuman(r.Total)
totalLine := totalLabel + dotsPad(totalLabel, totalDur, innerW) + totalDur
lines = append(lines, r.Styles.Total.Render(totalLine), hint)
body := strings.Join(lines, "\n")
return lipgloss.NewStyle().Width(r.Width).Height(r.Height).Render(body)
}
func dotsPad(left, right string, width int) string {
used := lipgloss.Width(left) + lipgloss.Width(right)
n := width - used
if n < 2 {
return " "
}
return " " + strings.Repeat("·", n-2) + " "
}
func (r *ReportView) ensureVisible() {
if len(r.Rows) == 0 {
r.Offset = 0
r.Cursor = 0
return
}
if r.Cursor < 0 {
r.Cursor = 0
}
if r.Cursor >= len(r.Rows) {
r.Cursor = len(r.Rows) - 1
}
visibleH := r.Height - 4
if visibleH < 1 {
visibleH = 1
}
if r.Cursor < r.Offset {
r.Offset = r.Cursor
}
if r.Cursor >= r.Offset+visibleH {
r.Offset = r.Cursor - visibleH + 1
}
if r.Offset < 0 {
r.Offset = 0
}
}
+17 -4
View File
@@ -1,7 +1,9 @@
package views
import (
"fmt"
"strings"
"unicode"
"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.
func (m StatusMenu) Selected() ticket.Status {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllStatuses) {
@@ -59,13 +73,12 @@ func (m StatusMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("Set status"))
for i, st := range ticket.AllStatuses {
label := " " + st.Label()
entry := fmt.Sprintf("%c %s", st.Shortcut(), st.Label())
if i == m.Cursor {
label = m.Styles.Selected.Render("▸ " + st.Label())
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
} else {
label = m.Styles.Normal.Render(label)
lines = append(lines, m.Styles.Normal.Render(" "+entry))
}
lines = append(lines, label)
}
body := strings.Join(lines, "\n")
w := m.Width
+77 -14
View File
@@ -3,6 +3,7 @@ package views
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
@@ -25,12 +26,14 @@ type TicketListStyles struct {
// TicketList is a custom viewport list of ticket cards.
type TicketList struct {
Items []ticket.Ticket
Cursor int
Offset int
Width int
Height int
Styles TicketListStyles
Items []ticket.Ticket
Cursor int
Offset int
Width int
Height int
Styles TicketListStyles
SpentByID map[string]time.Duration // optional; shown on selected card
ChecklistCursor int // index into checkboxes of the selected ticket
}
// 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.Offset = 0
l.clampChecklistCursor()
l.ensureVisible()
}
@@ -68,6 +72,7 @@ func (l *TicketList) Len() int { return len(l.Items) }
func (l *TicketList) MoveDown() {
if l.Cursor < len(l.Items)-1 {
l.Cursor++
l.ChecklistCursor = 0
l.ensureVisible()
}
}
@@ -76,6 +81,7 @@ func (l *TicketList) MoveDown() {
func (l *TicketList) MoveUp() {
if l.Cursor > 0 {
l.Cursor--
l.ChecklistCursor = 0
l.ensureVisible()
}
}
@@ -85,10 +91,61 @@ func (l *TicketList) Select(idx int) {
if idx < 0 || idx >= len(l.Items) {
return
}
if l.Cursor != idx {
l.ChecklistCursor = 0
}
l.Cursor = idx
l.clampChecklistCursor()
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.
func (l *TicketList) View() string {
if l.Height < 1 {
@@ -124,12 +181,15 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
var content string
if selected {
// Expanded: headline + glamour-rendered description (fixed dark style).
desc := strings.TrimSpace(t.Description)
if desc != "" {
content = headerLine + "\n" + RenderMarkdown(desc, innerWidth)
} else {
content = headerLine
// Expanded: headline + optional spent + description (interactive checkboxes).
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 {
content = headerLine
@@ -190,8 +250,11 @@ func (l *TicketList) estimateHeight(index int) int {
if index < 0 || index >= len(l.Items) {
return CollapsedCardHeight
}
if index == l.Cursor && strings.TrimSpace(l.Items[index].Description) != "" {
return lipgloss.Height(l.renderCard(l.Items[index], true))
if index == l.Cursor {
t := l.Items[index]
if strings.TrimSpace(t.Description) != "" || (l.SpentByID != nil && l.SpentByID[t.ID] > 0) {
return lipgloss.Height(l.renderCard(t, true))
}
}
return CollapsedCardHeight
}
+181
View File
@@ -0,0 +1,181 @@
package views
import (
"fmt"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// TimeEntriesStyles holds styles for the time-entry list.
type TimeEntriesStyles struct {
Title lipgloss.Style
Row lipgloss.Style
Selected lipgloss.Style
Meta lipgloss.Style
Total lipgloss.Style
Hint lipgloss.Style
Empty lipgloss.Style
}
// TimeEntriesView lists time entries for one ticket.
type TimeEntriesView struct {
TicketTitle string
Entries []ticket.TimeEntry
Total time.Duration
Cursor int
Offset int
Width int
Height int
Styles TimeEntriesStyles
}
// NewTimeEntriesView builds a sorted (newest first) entry list.
func NewTimeEntriesView(title string, entries []ticket.TimeEntry, width, height int, styles TimeEntriesStyles) TimeEntriesView {
cp := append([]ticket.TimeEntry(nil), entries...)
sort.Slice(cp, func(i, j int) bool {
return cp[i].Start.After(cp[j].Start)
})
var total time.Duration
for _, e := range cp {
total += e.Duration
}
v := TimeEntriesView{
TicketTitle: title,
Entries: cp,
Total: total,
Width: width,
Height: height,
Styles: styles,
}
v.ensureVisible()
return v
}
// SetSize updates viewport size.
func (v *TimeEntriesView) SetSize(width, height int) {
v.Width = width
v.Height = height
v.ensureVisible()
}
// Selected returns the highlighted entry.
func (v TimeEntriesView) Selected() (ticket.TimeEntry, bool) {
if len(v.Entries) == 0 || v.Cursor < 0 || v.Cursor >= len(v.Entries) {
return ticket.TimeEntry{}, false
}
return v.Entries[v.Cursor], true
}
// Len returns the number of entries.
func (v TimeEntriesView) Len() int { return len(v.Entries) }
// MoveDown moves selection down.
func (v *TimeEntriesView) MoveDown() {
if v.Cursor < len(v.Entries)-1 {
v.Cursor++
v.ensureVisible()
}
}
// MoveUp moves selection up.
func (v *TimeEntriesView) MoveUp() {
if v.Cursor > 0 {
v.Cursor--
v.ensureVisible()
}
}
// View renders the list.
func (v TimeEntriesView) View() string {
title := strings.TrimSpace(v.TicketTitle)
if title == "" {
title = "ticket"
}
head := v.Styles.Title.Render(fmt.Sprintf("Time entries · %s", truncateOneLine(title, max(8, v.Width-20))))
hint := v.Styles.Hint.Render("j/k navigate · e edit · x delete · esc back")
innerW := max(20, v.Width-2)
var lines []string
lines = append(lines, head, "")
visibleH := v.Height - 4
if visibleH < 1 {
visibleH = 1
}
if len(v.Entries) == 0 {
lines = append(lines, v.Styles.Empty.Render("No time entries for this ticket"))
} else {
end := v.Offset + visibleH
if end > len(v.Entries) {
end = len(v.Entries)
}
for i := v.Offset; i < end; i++ {
line := formatEntryLine(v.Entries[i], innerW)
if i == v.Cursor {
line = v.Styles.Selected.Render(line)
} else {
line = v.Styles.Row.Render(line)
}
lines = append(lines, line)
}
}
for len(lines) < visibleH+2 {
lines = append(lines, "")
}
totalLabel := "Total"
totalDur := ticket.FormatDurationHuman(v.Total)
totalLine := totalLabel + dotsPad(totalLabel, totalDur, innerW) + totalDur
lines = append(lines, v.Styles.Total.Render(totalLine), hint)
body := strings.Join(lines, "\n")
return lipgloss.NewStyle().Width(v.Width).Height(v.Height).Render(body)
}
func formatEntryLine(e ticket.TimeEntry, width int) string {
when := e.Start.Local().Format("2006-01-02 15:04")
if e.Start.IsZero() {
when = "—"
}
dur := ticket.FormatDurationHuman(e.Duration)
src := e.Source
if src == "" {
src = "?"
}
left := when + " · " + dur + " · " + src
return truncateOneLine(left, width)
}
func (v *TimeEntriesView) ensureVisible() {
if len(v.Entries) == 0 {
v.Offset = 0
v.Cursor = 0
return
}
if v.Cursor < 0 {
v.Cursor = 0
}
if v.Cursor >= len(v.Entries) {
v.Cursor = len(v.Entries) - 1
}
visibleH := v.Height - 4
if visibleH < 1 {
visibleH = 1
}
if v.Cursor < v.Offset {
v.Offset = v.Cursor
}
if v.Cursor >= v.Offset+visibleH {
v.Offset = v.Cursor - visibleH + 1
}
if v.Offset < 0 {
v.Offset = 0
}
}