From 7666715f6c48c177c5eeb0328c38a9276e9d703b Mon Sep 17 00:00:00 2001 From: Skruecken Date: Mon, 19 Oct 2026 14:19:10 +0200 Subject: [PATCH] Allow moving tickets between projects with P. Co-authored-by: Cursor --- internal/command/move_test.go | 57 ++++++++++++++++++ internal/command/persist.go | 9 +++ internal/command/ticket_cmds.go | 44 +++++++++++++- internal/ticket/jsonstore.go | 67 +++++++++++++++++++++ internal/ticket/jsonstore_test.go | 30 ++++++++++ internal/ticket/jsontimestore.go | 28 +++++++++ internal/ticket/jsontimestore_test.go | 35 +++++++++++ internal/ticket/store.go | 1 + internal/ticket/timestore.go | 1 + internal/ui/app.go | 86 ++++++++++++++++++++++++++- internal/ui/helpbar.go | 8 +++ internal/ui/keys.go | 7 ++- internal/ui/layout.go | 2 +- 13 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 internal/command/move_test.go diff --git a/internal/command/move_test.go b/internal/command/move_test.go new file mode 100644 index 0000000..21ecd0c --- /dev/null +++ b/internal/command/move_test.go @@ -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) + } +} diff --git a/internal/command/persist.go b/internal/command/persist.go index ea2aa25..541cf3a 100644 --- a/internal/command/persist.go +++ b/internal/command/persist.go @@ -38,6 +38,7 @@ const ( typeAddTime = "add_time" typeRemoveTime = "remove_time" typeUpdateTime = "update_time" + typeMove = "move" ) // HistoryPath returns DataDir/history.json. @@ -73,6 +74,9 @@ func encodeCmd(cmd Command) (persistedCmd, error) { 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) } @@ -119,6 +123,11 @@ func decodeCmd(rec persistedCmd, store ticket.Store, timeStore ticket.TimeStore) 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) } diff --git a/internal/command/ticket_cmds.go b/internal/command/ticket_cmds.go index b792695..ad9b52f 100644 --- a/internal/command/ticket_cmds.go +++ b/internal/command/ticket_cmds.go @@ -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 +} diff --git a/internal/ticket/jsonstore.go b/internal/ticket/jsonstore.go index f3280f5..1a4b091 100644 --- a/internal/ticket/jsonstore.go +++ b/internal/ticket/jsonstore.go @@ -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() diff --git a/internal/ticket/jsonstore_test.go b/internal/ticket/jsonstore_test.go index 670aac4..c2c7874 100644 --- a/internal/ticket/jsonstore_test.go +++ b/internal/ticket/jsonstore_test.go @@ -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) + } +} diff --git a/internal/ticket/jsontimestore.go b/internal/ticket/jsontimestore.go index 79949bb..a7e4dc1 100644 --- a/internal/ticket/jsontimestore.go +++ b/internal/ticket/jsontimestore.go @@ -153,6 +153,34 @@ func (s *JSONTimeStore) Delete(id string) error { 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() diff --git a/internal/ticket/jsontimestore_test.go b/internal/ticket/jsontimestore_test.go index 0e846b7..2192f12 100644 --- a/internal/ticket/jsontimestore_test.go +++ b/internal/ticket/jsontimestore_test.go @@ -91,3 +91,38 @@ func TestJSONTimeStoreDeleteByProject(t *testing.T) { 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) + } + } + } +} diff --git a/internal/ticket/store.go b/internal/ticket/store.go index 027809b..bf02fe1 100644 --- a/internal/ticket/store.go +++ b/internal/ticket/store.go @@ -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 } diff --git a/internal/ticket/timestore.go b/internal/ticket/timestore.go index 38418ba..87009c4 100644 --- a/internal/ticket/timestore.go +++ b/internal/ticket/timestore.go @@ -10,6 +10,7 @@ type TimeStore interface { 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) diff --git a/internal/ui/app.go b/internal/ui/app.go index 3299add..9891aaa 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -39,6 +39,7 @@ const ( ModeCreate ModeSearch ModeProjects + ModeMoveTicket ModeStatus ModeBoard ModeConfirmDelete @@ -166,6 +167,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateInput(msg) case ModeProjects: return m.updateProjects(msg) + case ModeMoveTicket: + return m.updateMoveTicket(msg) case ModeStatus: return m.updateStatus(msg) case ModeBoard: @@ -185,7 +188,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch m.mode { - case ModeProjects: + case ModeProjects, ModeMoveTicket: m.projectList, cmd = m.projectList.Update(msg) case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit: m.input, cmd = m.input.Update(msg) @@ -216,7 +219,7 @@ func (m *Model) resizeLists() { bodyH = 1 } m.tickets.SetSize(m.width, bodyH) - if m.mode == ModeProjects { + if m.mode == ModeProjects || m.mode == ModeMoveTicket { m.projectList.SetSize(m.width, bodyH) } m.input.Width = max(10, m.width-4) @@ -258,6 +261,19 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = ModeProjects return m, nil + case key.Matches(msg, m.keys.MoveTicket): + m.clearStatus() + if _, ok := m.tickets.Selected(); !ok { + m.setError(fmt.Errorf("no ticket selected")) + return m, nil + } + if err := m.openProjects(); err != nil { + m.setError(err) + return m, nil + } + m.mode = ModeMoveTicket + return m, nil + case key.Matches(msg, m.keys.Board): m.clearStatus() m.boardMenu = views.NewBoardMenu(m.boardFilter, m.width, views.StatusMenuStyles{ @@ -716,6 +732,37 @@ func (m Model) updateReportGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m Model) updateMoveTicket(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Escape): + m.mode = ModeList + m.clearStatus() + return m, nil + + case key.Matches(msg, m.keys.Enter): + item, ok := m.projectList.SelectedItem().(views.ProjectItem) + if !ok { + return m, nil + } + if err := m.moveSelectedToProject(item.Project.ID); err != nil { + m.setError(err) + return m, nil + } + m.mode = ModeList + return m, nil + } + + if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 { + if views.JumpProjectByLetter(&m.projectList, msg.Runes[0]) { + return m, nil + } + } + + var cmd tea.Cmd + m.projectList, cmd = m.projectList.Update(msg) + return m, cmd +} + func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Escape): @@ -994,6 +1041,41 @@ func (m *Model) switchProject(id string) error { return m.reloadTickets() } +func (m *Model) moveSelectedToProject(toProjectID string) error { + t, ok := m.tickets.Selected() + if !ok { + return fmt.Errorf("no ticket selected") + } + if toProjectID == t.ProjectID { + m.setStatus("already there") + return nil + } + if err := m.projects.Ensure(toProjectID); err != nil { + return err + } + after := t + after.ProjectID = toProjectID + if err := m.exec.Do(&command.MoveTicket{ + Store: m.store, + TimeStore: m.timeStore, + Before: t, + After: after, + }); err != nil { + return err + } + if m.activeTimer != nil && m.activeTimer.TicketID == t.ID && m.timeStore != nil { + m.activeTimer.ProjectID = toProjectID + if err := m.timeStore.SaveActiveTimer(m.activeTimer); err != nil { + return err + } + } + if err := m.reloadTickets(); err != nil { + return err + } + m.setStatus(fmt.Sprintf("moved → %s", toProjectID)) + return nil +} + func (m *Model) countTickets(projectID string) (int, error) { tickets, err := m.store.List(projectID) if err != nil { diff --git a/internal/ui/helpbar.go b/internal/ui/helpbar.go index acfeb71..8409dae 100644 --- a/internal/ui/helpbar.go +++ b/internal/ui/helpbar.go @@ -37,6 +37,13 @@ func (m Model) helpEntries() []helpEntry { {"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"}, @@ -99,6 +106,7 @@ func (m Model) helpEntries() []helpEntry { {"u/U", "undo/redo"}, {"/", "search"}, {"p", "project"}, + {"P", "move"}, {"l", "list"}, {"s", "status"}, {"x", "delete"}, diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 693d3bc..6d54c53 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -9,6 +9,7 @@ type KeyMap struct { 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 @@ -51,6 +52,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"), @@ -124,7 +129,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.Timer, k.TimeManual, k.TimeEntries, k.Report, 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. diff --git a/internal/ui/layout.go b/internal/ui/layout.go index bd915b2..d0e8946 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -76,7 +76,7 @@ func (m Model) renderBody(height int) string { menu := m.groupByMenu.View() return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu) - case ModeProjects: + case ModeProjects, ModeMoveTicket: m.projectList.SetSize(m.width, height) return m.projectList.View()