cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: interactive task viewing

+1921 -116
+118 -11
cmd/commands.go
··· 13 13 ) 14 14 15 15 func rootCmd() *cobra.Command { 16 - return &cobra.Command{ 16 + root := &cobra.Command{ 17 17 Use: "noteleaf", 18 18 Long: ui.Georgia.ColoredInViewport(), 19 19 Short: "A TaskWarrior-inspired CLI with notes, media queues and reading lists", ··· 27 27 return nil 28 28 }, 29 29 } 30 + 31 + root.SetHelpCommand(&cobra.Command{Hidden: true}) 32 + cobra.EnableCommandSorting = false 33 + 34 + root.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"}) 35 + root.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) 36 + 37 + return root 30 38 } 31 39 32 40 func todoCmd() *cobra.Command { 33 41 root := &cobra.Command{ 34 - Use: "todo", 35 - Short: "task management", 42 + Use: "todo", 43 + Aliases: []string{"task"}, 44 + Short: "task management", 36 45 } 37 46 38 47 root.AddCommand(&cobra.Command{ ··· 45 54 }, 46 55 }) 47 56 48 - root.AddCommand(&cobra.Command{ 57 + listCmd := &cobra.Command{ 49 58 Use: "list", 50 59 Short: "List tasks", 51 60 Aliases: []string{"ls"}, 61 + Long: `List tasks with optional filtering and display modes. 62 + 63 + By default, shows tasks in an interactive TaskWarrior-like interface. 64 + Use --static to show a simple text list instead. 65 + Use --all to show all tasks, otherwise only pending tasks are shown.`, 52 66 RunE: func(cmd *cobra.Command, args []string) error { 53 - return handlers.ListTasks(cmd.Context(), args) 67 + static, _ := cmd.Flags().GetBool("static") 68 + showAll, _ := cmd.Flags().GetBool("all") 69 + status, _ := cmd.Flags().GetString("status") 70 + priority, _ := cmd.Flags().GetString("priority") 71 + project, _ := cmd.Flags().GetString("project") 72 + 73 + return handlers.ListTasks(cmd.Context(), static, showAll, status, priority, project) 54 74 }, 55 - }) 75 + } 76 + listCmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)") 77 + listCmd.Flags().Bool("static", false, "Use static text output instead of interactive") 78 + listCmd.Flags().BoolP("all", "a", false, "Show all tasks (default: pending only)") 79 + listCmd.Flags().String("status", "", "Filter by status") 80 + listCmd.Flags().String("priority", "", "Filter by priority") 81 + listCmd.Flags().String("project", "", "Filter by project") 82 + root.AddCommand(listCmd) 56 83 57 84 root.AddCommand(&cobra.Command{ 58 85 Use: "view [task-id]", ··· 124 151 return root 125 152 } 126 153 127 - func movieCmd() *cobra.Command { 154 + func mediaCmd() *cobra.Command { 155 + root := &cobra.Command{ 156 + Use: "media", 157 + Short: "Manage media queues (books, movies, TV shows)", 158 + } 159 + 160 + root.AddCommand(bookMediaCmd()) 161 + root.AddCommand(movieMediaCmd()) 162 + root.AddCommand(tvMediaCmd()) 163 + 164 + return root 165 + } 166 + 167 + func movieMediaCmd() *cobra.Command { 128 168 root := &cobra.Command{ 129 169 Use: "movie", 130 170 Short: "Manage movie watch queue", ··· 152 192 }, 153 193 }) 154 194 195 + root.AddCommand(&cobra.Command{ 196 + Use: "watched [id]", 197 + Short: "Mark movie as watched", 198 + Aliases: []string{"seen"}, 199 + Args: cobra.ExactArgs(1), 200 + RunE: func(cmd *cobra.Command, args []string) error { 201 + fmt.Printf("Marking movie %s as watched\n", args[0]) 202 + // TODO: Implement movie watched status 203 + return nil 204 + }, 205 + }) 206 + 207 + root.AddCommand(&cobra.Command{ 208 + Use: "remove [id]", 209 + Short: "Remove movie from queue", 210 + Aliases: []string{"rm"}, 211 + Args: cobra.ExactArgs(1), 212 + RunE: func(cmd *cobra.Command, args []string) error { 213 + fmt.Printf("Removing movie %s from queue\n", args[0]) 214 + // TODO: Implement movie removal 215 + return nil 216 + }, 217 + }) 218 + 155 219 return root 156 220 } 157 221 158 - func tvCmd() *cobra.Command { 222 + func tvMediaCmd() *cobra.Command { 159 223 root := &cobra.Command{ 160 224 Use: "tv", 161 225 Short: "Manage TV show watch queue", ··· 183 247 }, 184 248 }) 185 249 250 + root.AddCommand(&cobra.Command{ 251 + Use: "watched [id]", 252 + Short: "Mark TV show/episodes as watched", 253 + Aliases: []string{"seen"}, 254 + Args: cobra.ExactArgs(1), 255 + RunE: func(cmd *cobra.Command, args []string) error { 256 + fmt.Printf("Marking TV show %s as watched\n", args[0]) 257 + // TODO: Implement TV show watched status 258 + return nil 259 + }, 260 + }) 261 + 262 + root.AddCommand(&cobra.Command{ 263 + Use: "remove [id]", 264 + Short: "Remove TV show from queue", 265 + Aliases: []string{"rm"}, 266 + Args: cobra.ExactArgs(1), 267 + RunE: func(cmd *cobra.Command, args []string) error { 268 + fmt.Printf("Removing TV show %s from queue\n", args[0]) 269 + // TODO: Implement TV show removal 270 + return nil 271 + }, 272 + }) 273 + 186 274 return root 187 275 } 188 276 189 - func bookCmd() *cobra.Command { 277 + func bookMediaCmd() *cobra.Command { 190 278 root := &cobra.Command{ 191 279 Use: "book", 192 280 Short: "Manage reading list", ··· 418 506 } 419 507 420 508 func setupCmd() *cobra.Command { 421 - return &cobra.Command{ 509 + handler, err := handlers.NewSeedHandler() 510 + if err != nil { 511 + log.Fatalf("failed to instantiate seed handler: %v", err) 512 + } 513 + 514 + root := &cobra.Command{ 422 515 Use: "setup", 423 - Short: "Initialize the application database and configuration", 516 + Short: "Initialize and manage application setup", 424 517 RunE: func(cmd *cobra.Command, args []string) error { 425 518 return handlers.Setup(cmd.Context(), args) 426 519 }, 427 520 } 521 + 522 + seedCmd := &cobra.Command{ 523 + Use: "seed", 524 + Short: "Populate database with test data", 525 + Long: "Add sample tasks, books, and notes to the database for testing and demonstration purposes", 526 + RunE: func(cmd *cobra.Command, args []string) error { 527 + force, _ := cmd.Flags().GetBool("force") 528 + return handler.Seed(cmd.Context(), force) 529 + }, 530 + } 531 + seedCmd.Flags().BoolP("force", "f", false, "Clear existing data and re-seed") 532 + 533 + root.AddCommand(seedCmd) 534 + return root 428 535 } 429 536 430 537 func confCmd() *cobra.Command {
+11 -6
cmd/main.go
··· 51 51 defer app.Close() 52 52 53 53 root := rootCmd() 54 - commands := []func() *cobra.Command{ 55 - setupCmd, resetCmd, statusCmd, todoCmd, 56 - movieCmd, noteCmd, tvCmd, bookCmd, confCmd, 54 + core := []func() *cobra.Command{todoCmd, mediaCmd, noteCmd} 55 + mgmt := []func() *cobra.Command{statusCmd, confCmd, setupCmd, resetCmd} 56 + 57 + for _, cmdFunc := range core { 58 + cmd := cmdFunc() 59 + cmd.GroupID = "core" 60 + root.AddCommand(cmd) 57 61 } 58 62 59 - for _, cmdFunc := range commands { 63 + for _, cmdFunc := range mgmt { 60 64 cmd := cmdFunc() 65 + cmd.GroupID = "management" 61 66 root.AddCommand(cmd) 62 67 } 63 68 64 - options := []fang.Option{ 69 + opts := []fang.Option{ 65 70 fang.WithVersion("0.1.0"), 66 71 fang.WithoutCompletions(), 67 72 fang.WithColorSchemeFunc(ui.NoteleafColorScheme), 68 73 } 69 74 70 - if err := fang.Execute(context.Background(), root, options...); err != nil { 75 + if err := fang.Execute(context.Background(), root, opts...); err != nil { 71 76 os.Exit(1) 72 77 } 73 78 }
+6 -2
go.sum
··· 2 2 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 3 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 4 4 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 5 + github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 6 + github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 5 7 github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 6 8 github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 9 + github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 10 + github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 11 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 8 12 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 9 13 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= ··· 26 30 github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 27 31 github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= 28 32 github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= 29 - github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 30 - github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 31 33 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= 32 34 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 33 35 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU= ··· 73 75 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 74 76 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 75 77 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 78 + github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 79 + github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 76 80 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 77 81 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 78 82 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+155
internal/handlers/seed.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "math/rand" 7 + "time" 8 + 9 + "github.com/stormlightlabs/noteleaf/internal/repo" 10 + "github.com/stormlightlabs/noteleaf/internal/store" 11 + "github.com/stormlightlabs/noteleaf/internal/ui" 12 + "github.com/stormlightlabs/noteleaf/internal/utils" 13 + ) 14 + 15 + // SeedHandler handles database seeding operations 16 + type SeedHandler struct { 17 + db *store.Database 18 + config *store.Config 19 + repos *repo.Repositories 20 + } 21 + 22 + // NewSeedHandler creates a new seed handler 23 + func NewSeedHandler() (*SeedHandler, error) { 24 + db, err := store.NewDatabase() 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to initialize database: %w", err) 27 + } 28 + 29 + config, err := store.LoadConfig() 30 + if err != nil { 31 + return nil, fmt.Errorf("failed to load configuration: %w", err) 32 + } 33 + 34 + repos := repo.NewRepositories(db.DB) 35 + 36 + return &SeedHandler{ 37 + db: db, 38 + config: config, 39 + repos: repos, 40 + }, nil 41 + } 42 + 43 + // Close cleans up resources 44 + func (h *SeedHandler) Close() error { 45 + if h.db != nil { 46 + return h.db.Close() 47 + } 48 + return nil 49 + } 50 + 51 + // Seed populates the database with test data for demonstration and testing 52 + func (h *SeedHandler) Seed(ctx context.Context, force bool) error { 53 + logger := utils.GetLogger() 54 + logger.Info("Seeding database with test data") 55 + 56 + if force { 57 + fmt.Println("Clearing existing data...") 58 + if err := h.clearAllData(); err != nil { 59 + return fmt.Errorf("failed to clear existing data: %w", err) 60 + } 61 + } 62 + 63 + fmt.Println("Seeding database with test data...") 64 + 65 + // Seed tasks 66 + tasks := []struct { 67 + description string 68 + project string 69 + priority string 70 + status string 71 + }{ 72 + {"Review quarterly report", "work", "high", "pending"}, 73 + {"Plan vacation itinerary", "personal", "medium", "pending"}, 74 + {"Fix bug in user authentication", "development", "high", "pending"}, 75 + {"Read \"Clean Code\" book", "learning", "low", "pending"}, 76 + {"Update project documentation", "work", "medium", "completed"}, 77 + } 78 + 79 + for _, task := range tasks { 80 + if err := h.seedTask(task.description, task.project, task.priority, task.status); err != nil { 81 + logger.Warn("Failed to seed task", "description", task.description, "error", err) 82 + } 83 + } 84 + 85 + // Seed books 86 + books := []struct { 87 + title string 88 + author string 89 + status string 90 + progress int 91 + }{ 92 + {"The Go Programming Language", "Alan Donovan", "reading", 45}, 93 + {"Clean Code", "Robert Martin", "queued", 0}, 94 + {"Design Patterns", "Gang of Four", "finished", 100}, 95 + {"The Pragmatic Programmer", "Andy Hunt", "queued", 0}, 96 + {"Effective Go", "Various", "reading", 75}, 97 + } 98 + 99 + for _, book := range books { 100 + if err := h.seedBook(book.title, book.author, book.status, book.progress); err != nil { 101 + logger.Warn("Failed to seed book", "title", book.title, "error", err) 102 + } 103 + } 104 + 105 + fmt.Printf("Successfully seeded database with %d tasks and %d books\n", len(tasks), len(books)) 106 + fmt.Printf("\n%s\n", ui.Info.Render("Example commands to try:")) 107 + fmt.Printf(" %s\n", ui.Success.Render("noteleaf todo list")) 108 + fmt.Printf(" %s\n", ui.Success.Render("noteleaf media book list")) 109 + fmt.Printf(" %s\n", ui.Success.Render("noteleaf todo view 1")) 110 + 111 + return nil 112 + } 113 + 114 + func (h *SeedHandler) clearAllData() error { 115 + queries := []string{ 116 + "DELETE FROM tasks", 117 + "DELETE FROM books", 118 + "DELETE FROM notes", 119 + "DELETE FROM movies", 120 + "DELETE FROM tv_shows", 121 + "DELETE FROM sqlite_sequence WHERE name IN ('tasks', 'books', 'notes', 'movies', 'tv_shows')", 122 + } 123 + 124 + for _, query := range queries { 125 + if _, err := h.db.Exec(query); err != nil { 126 + return fmt.Errorf("failed to execute %s: %w", query, err) 127 + } 128 + } 129 + 130 + return nil 131 + } 132 + 133 + func (h *SeedHandler) seedTask(description, project, priority, status string) error { 134 + // Generate a simple UUID for the task (required field) 135 + uuid := h.generateSimpleUUID() 136 + query := `INSERT INTO tasks (uuid, description, project, priority, status, entry, modified) 137 + VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))` 138 + _, err := h.db.Exec(query, uuid, description, project, priority, status) 139 + return err 140 + } 141 + 142 + func (h *SeedHandler) seedBook(title, author, status string, progress int) error { 143 + query := `INSERT INTO books (title, author, status, progress, added) 144 + VALUES (?, ?, ?, ?, datetime('now'))` 145 + _, err := h.db.Exec(query, title, author, status, progress) 146 + return err 147 + } 148 + 149 + // generateSimpleUUID creates a simple UUID for seeding (not cryptographically secure, but sufficient for test data) 150 + func (h *SeedHandler) generateSimpleUUID() string { 151 + now := time.Now() 152 + // Add random component to avoid collisions during rapid seeding 153 + randomNum := rand.Intn(10000) 154 + return fmt.Sprintf("seed-task-%d-%d-%d", now.Unix(), now.UnixNano()%1000000, randomNum) 155 + }
+250
internal/handlers/seed_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "runtime" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/stormlightlabs/noteleaf/internal/store" 11 + ) 12 + 13 + func setupSeedTest(t *testing.T) (string, func()) { 14 + tempDir, err := os.MkdirTemp("", "noteleaf-seed-test-*") 15 + if err != nil { 16 + t.Fatalf("Failed to create temp dir: %v", err) 17 + } 18 + 19 + oldConfigHome := os.Getenv("XDG_CONFIG_HOME") 20 + os.Setenv("XDG_CONFIG_HOME", tempDir) 21 + 22 + cleanup := func() { 23 + os.Setenv("XDG_CONFIG_HOME", oldConfigHome) 24 + os.RemoveAll(tempDir) 25 + } 26 + 27 + ctx := context.Background() 28 + err = Setup(ctx, []string{}) 29 + if err != nil { 30 + cleanup() 31 + t.Fatalf("Failed to setup database: %v", err) 32 + } 33 + 34 + return tempDir, cleanup 35 + } 36 + 37 + func countRecords(t *testing.T, db *store.Database, table string) int { 38 + t.Helper() 39 + 40 + var count int 41 + query := "SELECT COUNT(*) FROM " + table 42 + err := db.QueryRow(query).Scan(&count) 43 + if err != nil { 44 + t.Fatalf("Failed to count records in %s: %v", table, err) 45 + } 46 + return count 47 + } 48 + 49 + func getTaskRecord(t *testing.T, db *store.Database, id int) (description, project, priority, status string) { 50 + t.Helper() 51 + 52 + query := "SELECT description, project, priority, status FROM tasks WHERE id = ?" 53 + err := db.QueryRow(query, id).Scan(&description, &project, &priority, &status) 54 + if err != nil { 55 + t.Fatalf("Failed to get task record: %v", err) 56 + } 57 + return description, project, priority, status 58 + } 59 + 60 + func getBookRecord(t *testing.T, db *store.Database, id int) (title, author, status string, progress int) { 61 + t.Helper() 62 + 63 + query := "SELECT title, author, status, progress FROM books WHERE id = ?" 64 + err := db.QueryRow(query, id).Scan(&title, &author, &status, &progress) 65 + if err != nil { 66 + t.Fatalf("Failed to get book record: %v", err) 67 + } 68 + return title, author, status, progress 69 + } 70 + 71 + func TestSeedHandler(t *testing.T) { 72 + _, cleanup := setupSeedTest(t) 73 + defer cleanup() 74 + 75 + handler, err := NewSeedHandler() 76 + if err != nil { 77 + t.Fatalf("Failed to create seed handler: %v", err) 78 + } 79 + defer handler.Close() 80 + 81 + ctx := context.Background() 82 + 83 + t.Run("New", func(t *testing.T) { 84 + t.Run("creates handler successfully", func(t *testing.T) { 85 + testHandler, err := NewSeedHandler() 86 + if err != nil { 87 + t.Fatalf("NewSeedHandler failed: %v", err) 88 + } 89 + if testHandler == nil { 90 + t.Fatal("Handler should not be nil") 91 + } 92 + defer testHandler.Close() 93 + 94 + if testHandler.db == nil { 95 + t.Error("Handler database should not be nil") 96 + } 97 + if testHandler.config == nil { 98 + t.Error("Handler config should not be nil") 99 + } 100 + if testHandler.repos == nil { 101 + t.Error("Handler repos should not be nil") 102 + } 103 + }) 104 + 105 + t.Run("handles database initialization error", func(t *testing.T) { 106 + originalXDG := os.Getenv("XDG_CONFIG_HOME") 107 + originalHome := os.Getenv("HOME") 108 + 109 + if runtime.GOOS == "windows" { 110 + originalAppData := os.Getenv("APPDATA") 111 + os.Unsetenv("APPDATA") 112 + defer os.Setenv("APPDATA", originalAppData) 113 + } else { 114 + os.Unsetenv("XDG_CONFIG_HOME") 115 + os.Unsetenv("HOME") 116 + } 117 + 118 + defer func() { 119 + os.Setenv("XDG_CONFIG_HOME", originalXDG) 120 + os.Setenv("HOME", originalHome) 121 + }() 122 + 123 + _, err := NewSeedHandler() 124 + if err == nil { 125 + t.Error("NewSeedHandler should fail when database initialization fails") 126 + } 127 + if !strings.Contains(err.Error(), "failed to initialize database") { 128 + t.Errorf("Expected database error, got: %v", err) 129 + } 130 + }) 131 + }) 132 + 133 + t.Run("Seed", func(t *testing.T) { 134 + t.Run("seeds database with test data", func(t *testing.T) { 135 + err := handler.Seed(ctx, false) 136 + if err != nil { 137 + t.Fatalf("Seed failed: %v", err) 138 + } 139 + 140 + taskCount := countRecords(t, handler.db, "tasks") 141 + if taskCount != 5 { 142 + t.Errorf("Expected 5 tasks, got %d", taskCount) 143 + } 144 + 145 + bookCount := countRecords(t, handler.db, "books") 146 + if bookCount != 5 { 147 + t.Errorf("Expected 5 books, got %d", bookCount) 148 + } 149 + 150 + desc, proj, prio, status := getTaskRecord(t, handler.db, 1) 151 + if desc != "Review quarterly report" { 152 + t.Errorf("Expected 'Review quarterly report', got '%s'", desc) 153 + } 154 + if proj != "work" { 155 + t.Errorf("Expected 'work' project, got '%s'", proj) 156 + } 157 + if prio != "high" { 158 + t.Errorf("Expected 'high' priority, got '%s'", prio) 159 + } 160 + if status != "pending" { 161 + t.Errorf("Expected 'pending' status, got '%s'", status) 162 + } 163 + 164 + title, author, bookStatus, progress := getBookRecord(t, handler.db, 1) 165 + if title != "The Go Programming Language" { 166 + t.Errorf("Expected 'The Go Programming Language', got '%s'", title) 167 + } 168 + if author != "Alan Donovan" { 169 + t.Errorf("Expected 'Alan Donovan', got '%s'", author) 170 + } 171 + if bookStatus != "reading" { 172 + t.Errorf("Expected 'reading' status, got '%s'", bookStatus) 173 + } 174 + if progress != 45 { 175 + t.Errorf("Expected 45%% progress, got %d", progress) 176 + } 177 + }) 178 + 179 + t.Run("seeds without force flag preserves existing data", func(t *testing.T) { 180 + err := handler.Seed(ctx, false) 181 + if err != nil { 182 + t.Fatalf("First seed failed: %v", err) 183 + } 184 + 185 + initialTaskCount := countRecords(t, handler.db, "tasks") 186 + initialBookCount := countRecords(t, handler.db, "books") 187 + 188 + err = handler.Seed(ctx, false) 189 + if err != nil { 190 + t.Fatalf("Second seed failed: %v", err) 191 + } 192 + 193 + finalTaskCount := countRecords(t, handler.db, "tasks") 194 + finalBookCount := countRecords(t, handler.db, "books") 195 + 196 + expectedTasks := initialTaskCount + 5 197 + expectedBooks := initialBookCount + 5 198 + 199 + if finalTaskCount != expectedTasks { 200 + t.Errorf("Expected %d tasks after second seed, got %d", expectedTasks, finalTaskCount) 201 + } 202 + if finalBookCount != expectedBooks { 203 + t.Errorf("Expected %d books after second seed, got %d", expectedBooks, finalBookCount) 204 + } 205 + }) 206 + 207 + t.Run("force flag clears existing data before seeding", func(t *testing.T) { 208 + err := handler.Seed(ctx, false) 209 + if err != nil { 210 + t.Fatalf("Initial seed failed: %v", err) 211 + } 212 + 213 + if countRecords(t, handler.db, "tasks") == 0 { 214 + t.Fatal("No tasks found after initial seed") 215 + } 216 + if countRecords(t, handler.db, "books") == 0 { 217 + t.Fatal("No books found after initial seed") 218 + } 219 + 220 + err = handler.Seed(ctx, true) 221 + if err != nil { 222 + t.Fatalf("Force seed failed: %v", err) 223 + } 224 + 225 + taskCount := countRecords(t, handler.db, "tasks") 226 + bookCount := countRecords(t, handler.db, "books") 227 + 228 + if taskCount != 5 { 229 + t.Errorf("Expected exactly 5 tasks after force seed, got %d", taskCount) 230 + } 231 + if bookCount != 5 { 232 + t.Errorf("Expected exactly 5 books after force seed, got %d", bookCount) 233 + } 234 + 235 + _, _, _, _ = getTaskRecord(t, handler.db, 1) // Should not error 236 + _, _, _, _ = getBookRecord(t, handler.db, 1) // Should not error 237 + }) 238 + }) 239 + 240 + t.Run("Close", func(t *testing.T) { 241 + testHandler, err := NewSeedHandler() 242 + if err != nil { 243 + t.Fatalf("Failed to create test handler: %v", err) 244 + } 245 + 246 + if err = testHandler.Close(); err != nil { 247 + t.Errorf("Close should succeed: %v", err) 248 + } 249 + }) 250 + }
+46 -54
internal/handlers/tasks.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "slices" 6 7 "strconv" 7 8 "strings" 8 9 "time" ··· 11 12 "github.com/stormlightlabs/noteleaf/internal/models" 12 13 "github.com/stormlightlabs/noteleaf/internal/repo" 13 14 "github.com/stormlightlabs/noteleaf/internal/store" 15 + "github.com/stormlightlabs/noteleaf/internal/ui" 14 16 ) 15 17 16 18 // TaskHandler handles all task-related commands ··· 63 65 } 64 66 65 67 description := strings.Join(args, " ") 66 - 68 + 67 69 task := &models.Task{ 68 70 UUID: uuid.New().String(), 69 71 Description: description, ··· 80 82 } 81 83 82 84 // ListTasks lists all tasks with optional filtering 83 - func ListTasks(ctx context.Context, args []string) error { 85 + func ListTasks(ctx context.Context, static, showAll bool, status, priority, project string) error { 84 86 handler, err := NewTaskHandler() 85 87 if err != nil { 86 88 return fmt.Errorf("failed to initialize task handler: %w", err) 87 89 } 88 90 defer handler.Close() 89 91 90 - return handler.listTasks(ctx, args) 92 + if static { 93 + return handler.listTasksStatic(ctx, showAll, status, priority, project) 94 + } 95 + 96 + return handler.listTasksInteractive(ctx, showAll, status, priority, project) 91 97 } 92 98 93 - func (h *TaskHandler) listTasks(ctx context.Context, args []string) error { 94 - opts := repo.TaskListOptions{} 95 - 96 - // Parse arguments for filtering 97 - for i, arg := range args { 98 - switch { 99 - case arg == "--status" && i+1 < len(args): 100 - opts.Status = args[i+1] 101 - case arg == "--priority" && i+1 < len(args): 102 - opts.Priority = args[i+1] 103 - case arg == "--project" && i+1 < len(args): 104 - opts.Project = args[i+1] 105 - case arg == "--search" && i+1 < len(args): 106 - opts.Search = args[i+1] 107 - case arg == "--limit" && i+1 < len(args): 108 - if limit, err := strconv.Atoi(args[i+1]); err == nil { 109 - opts.Limit = limit 110 - } 111 - } 99 + func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project string) error { 100 + opts := repo.TaskListOptions{ 101 + Status: status, 102 + Priority: priority, 103 + Project: project, 112 104 } 113 105 114 - // Default to showing pending tasks only 115 - if opts.Status == "" { 106 + // Default to showing pending tasks only unless --all is specified 107 + if !showAll && opts.Status == "" { 116 108 opts.Status = "pending" 117 109 } 118 110 ··· 134 126 return nil 135 127 } 136 128 129 + func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project string) error { 130 + taskList := ui.NewTaskList(h.repos.Tasks, ui.TaskListOptions{ 131 + ShowAll: showAll, 132 + Status: status, 133 + Priority: priority, 134 + Project: project, 135 + Static: false, 136 + }) 137 + 138 + return taskList.Browse(ctx) 139 + } 140 + 137 141 // UpdateTask updates an existing task 138 142 func UpdateTask(ctx context.Context, args []string) error { 139 143 handler, err := NewTaskHandler() ··· 190 194 i++ 191 195 case strings.HasPrefix(arg, "--add-tag="): 192 196 tag := strings.TrimPrefix(arg, "--add-tag=") 193 - if !contains(task.Tags, tag) { 197 + if !slices.Contains(task.Tags, tag) { 194 198 task.Tags = append(task.Tags, tag) 195 199 } 196 200 case strings.HasPrefix(arg, "--remove-tag="): ··· 229 233 var err error 230 234 231 235 if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { 232 - // Get task first to show what's being deleted 233 236 task, err = h.repos.Tasks.Get(ctx, id) 234 237 if err != nil { 235 238 return fmt.Errorf("failed to find task: %w", err) 236 239 } 237 - 240 + 238 241 err = h.repos.Tasks.Delete(ctx, id) 239 242 } else { 240 - // Get by UUID first 241 243 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 242 244 if err != nil { 243 245 return fmt.Errorf("failed to find task: %w", err) 244 246 } 245 - 247 + 246 248 err = h.repos.Tasks.Delete(ctx, task.ID) 247 249 } 248 250 ··· 336 338 return nil 337 339 } 338 340 339 - // Helper functions 340 341 func (h *TaskHandler) printTask(task *models.Task) { 341 342 fmt.Printf("[%d] %s", task.ID, task.Description) 342 - 343 + 343 344 if task.Status != "pending" { 344 345 fmt.Printf(" (%s)", task.Status) 345 346 } 346 - 347 + 347 348 if task.Priority != "" { 348 349 fmt.Printf(" [%s]", task.Priority) 349 350 } 350 - 351 + 351 352 if task.Project != "" { 352 353 fmt.Printf(" +%s", task.Project) 353 354 } 354 - 355 + 355 356 if len(task.Tags) > 0 { 356 357 fmt.Printf(" @%s", strings.Join(task.Tags, " @")) 357 358 } 358 - 359 + 359 360 if task.Due != nil { 360 361 fmt.Printf(" (due: %s)", task.Due.Format("2006-01-02")) 361 362 } 362 - 363 + 363 364 fmt.Println() 364 365 } 365 366 ··· 368 369 fmt.Printf("UUID: %s\n", task.UUID) 369 370 fmt.Printf("Description: %s\n", task.Description) 370 371 fmt.Printf("Status: %s\n", task.Status) 371 - 372 + 372 373 if task.Priority != "" { 373 374 fmt.Printf("Priority: %s\n", task.Priority) 374 375 } 375 - 376 + 376 377 if task.Project != "" { 377 378 fmt.Printf("Project: %s\n", task.Project) 378 379 } 379 - 380 + 380 381 if len(task.Tags) > 0 { 381 382 fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", ")) 382 383 } 383 - 384 + 384 385 if task.Due != nil { 385 386 fmt.Printf("Due: %s\n", task.Due.Format("2006-01-02 15:04")) 386 387 } 387 - 388 + 388 389 fmt.Printf("Created: %s\n", task.Entry.Format("2006-01-02 15:04")) 389 390 fmt.Printf("Modified: %s\n", task.Modified.Format("2006-01-02 15:04")) 390 - 391 + 391 392 if task.Start != nil { 392 393 fmt.Printf("Started: %s\n", task.Start.Format("2006-01-02 15:04")) 393 394 } 394 - 395 + 395 396 if task.End != nil { 396 397 fmt.Printf("Completed: %s\n", task.End.Format("2006-01-02 15:04")) 397 398 } 398 - 399 + 399 400 if len(task.Annotations) > 0 { 400 401 fmt.Printf("Annotations:\n") 401 402 for _, annotation := range task.Annotations { ··· 404 405 } 405 406 } 406 407 407 - func contains(slice []string, item string) bool { 408 - for _, s := range slice { 409 - if s == item { 410 - return true 411 - } 412 - } 413 - return false 414 - } 415 - 416 408 func removeString(slice []string, item string) []string { 417 409 var result []string 418 410 for _, s := range slice { ··· 421 413 } 422 414 } 423 415 return result 424 - } 416 + }
+14 -43
internal/handlers/tasks_test.go
··· 4 4 "context" 5 5 "os" 6 6 "runtime" 7 + "slices" 7 8 "strconv" 8 9 "strings" 9 10 "testing" ··· 180 181 t.Fatalf("Failed to create task2: %v", err) 181 182 } 182 183 183 - t.Run("lists pending tasks by default", func(t *testing.T) { 184 - args := []string{} 185 - 186 - err := ListTasks(ctx, args) 184 + t.Run("lists pending tasks by default (static mode)", func(t *testing.T) { 185 + err := ListTasks(ctx, true, false, "", "", "") 187 186 if err != nil { 188 187 t.Errorf("ListTasks failed: %v", err) 189 188 } 190 189 }) 191 190 192 - t.Run("filters by status", func(t *testing.T) { 193 - args := []string{"--status", "completed"} 194 - 195 - err := ListTasks(ctx, args) 191 + t.Run("filters by status (static mode)", func(t *testing.T) { 192 + err := ListTasks(ctx, true, false, "completed", "", "") 196 193 if err != nil { 197 194 t.Errorf("ListTasks with status filter failed: %v", err) 198 195 } 199 196 }) 200 197 201 - t.Run("filters by priority", func(t *testing.T) { 202 - args := []string{"--priority", "A"} 203 - 204 - err := ListTasks(ctx, args) 198 + t.Run("filters by priority (static mode)", func(t *testing.T) { 199 + err := ListTasks(ctx, true, false, "", "A", "") 205 200 if err != nil { 206 201 t.Errorf("ListTasks with priority filter failed: %v", err) 207 202 } 208 203 }) 209 204 210 - t.Run("filters by project", func(t *testing.T) { 211 - args := []string{"--project", "work"} 212 - 213 - err := ListTasks(ctx, args) 205 + t.Run("filters by project (static mode)", func(t *testing.T) { 206 + err := ListTasks(ctx, true, false, "", "", "work") 214 207 if err != nil { 215 208 t.Errorf("ListTasks with project filter failed: %v", err) 216 209 } 217 210 }) 218 211 219 - t.Run("searches tasks", func(t *testing.T) { 220 - args := []string{"--search", "Task"} 221 - 222 - err := ListTasks(ctx, args) 223 - if err != nil { 224 - t.Errorf("ListTasks with search failed: %v", err) 225 - } 226 - }) 227 - 228 - t.Run("limits results", func(t *testing.T) { 229 - args := []string{"--limit", "1"} 230 - 231 - err := ListTasks(ctx, args) 212 + t.Run("show all tasks (static mode)", func(t *testing.T) { 213 + err := ListTasks(ctx, true, true, "", "", "") 232 214 if err != nil { 233 - t.Errorf("ListTasks with limit failed: %v", err) 215 + t.Errorf("ListTasks with show all failed: %v", err) 234 216 } 235 217 }) 236 218 }) ··· 683 665 }) 684 666 685 667 t.Run("Helper", func(t *testing.T) { 686 - t.Run("contains function", func(t *testing.T) { 687 - slice := []string{"a", "b", "c"} 688 - 689 - if !contains(slice, "b") { 690 - t.Error("Expected contains to return true for existing item") 691 - } 692 - 693 - if contains(slice, "d") { 694 - t.Error("Expected contains to return false for non-existing item") 695 - } 696 - }) 697 668 698 669 t.Run("removeString function", func(t *testing.T) { 699 670 slice := []string{"a", "b", "c", "b"} ··· 703 674 t.Errorf("Expected 2 items after removing 'b', got %d", len(result)) 704 675 } 705 676 706 - if contains(result, "b") { 677 + if slices.Contains(result, "b") { 707 678 t.Error("Expected 'b' to be removed from slice") 708 679 } 709 680 710 - if !contains(result, "a") || !contains(result, "c") { 681 + if !slices.Contains(result, "a") || !slices.Contains(result, "c") { 711 682 t.Error("Expected 'a' and 'c' to remain in slice") 712 683 } 713 684 })
+443
internal/ui/task_list.go
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "os" 8 + "strings" 9 + 10 + tea "github.com/charmbracelet/bubbletea" 11 + "github.com/charmbracelet/lipgloss" 12 + "github.com/stormlightlabs/noteleaf/internal/models" 13 + "github.com/stormlightlabs/noteleaf/internal/repo" 14 + ) 15 + 16 + // TaskRepository interface for dependency injection in tests 17 + type TaskRepository interface { 18 + List(ctx context.Context, opts repo.TaskListOptions) ([]*models.Task, error) 19 + Update(ctx context.Context, task *models.Task) error 20 + } 21 + 22 + // TaskListOptions configures the task list UI behavior 23 + type TaskListOptions struct { 24 + // Output destination (stdout for interactive, buffer for testing) 25 + Output io.Writer 26 + // Input source (stdin for interactive, strings reader for testing) 27 + Input io.Reader 28 + // Enable static mode (no interactive components) 29 + Static bool 30 + Status string 31 + Priority string 32 + Project string 33 + ShowAll bool 34 + } 35 + 36 + // TaskList handles task browsing and viewing UI 37 + type TaskList struct { 38 + repo TaskRepository 39 + opts TaskListOptions 40 + } 41 + 42 + // NewTaskList creates a new task list UI component 43 + func NewTaskList(repo TaskRepository, opts TaskListOptions) *TaskList { 44 + if opts.Output == nil { 45 + opts.Output = os.Stdout 46 + } 47 + if opts.Input == nil { 48 + opts.Input = os.Stdin 49 + } 50 + return &TaskList{repo: repo, opts: opts} 51 + } 52 + 53 + type taskListModel struct { 54 + tasks []*models.Task 55 + selected int 56 + viewing bool 57 + viewContent string 58 + err error 59 + repo TaskRepository 60 + opts TaskListOptions 61 + showAll bool 62 + // filter string 63 + } 64 + 65 + type tasksLoadedMsg []*models.Task 66 + type taskViewMsg string 67 + type errorTaskMsg error 68 + 69 + func (m taskListModel) Init() tea.Cmd { 70 + return m.loadTasks() 71 + } 72 + 73 + func (m taskListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 74 + switch msg := msg.(type) { 75 + case tea.KeyMsg: 76 + if m.viewing { 77 + switch msg.String() { 78 + case "q", "esc", "backspace": 79 + m.viewing = false 80 + m.viewContent = "" 81 + return m, nil 82 + } 83 + return m, nil 84 + } 85 + 86 + switch msg.String() { 87 + case "ctrl+c", "q": 88 + return m, tea.Quit 89 + case "up", "k": 90 + if m.selected > 0 { 91 + m.selected-- 92 + } 93 + case "down", "j": 94 + if m.selected < len(m.tasks)-1 { 95 + m.selected++ 96 + } 97 + case "enter", "v": 98 + if len(m.tasks) > 0 && m.selected < len(m.tasks) { 99 + return m, m.viewTask(m.tasks[m.selected]) 100 + } 101 + case "r": 102 + return m, m.loadTasks() 103 + case "a": 104 + m.showAll = !m.showAll 105 + return m, m.loadTasks() 106 + case "d": 107 + if len(m.tasks) > 0 && m.selected < len(m.tasks) { 108 + return m, m.markDone(m.tasks[m.selected]) 109 + } 110 + case "1", "2", "3", "4", "5", "6", "7", "8", "9": 111 + if idx := int(msg.String()[0] - '1'); idx < len(m.tasks) { 112 + m.selected = idx 113 + } 114 + } 115 + case tasksLoadedMsg: 116 + m.tasks = []*models.Task(msg) 117 + if m.selected >= len(m.tasks) && len(m.tasks) > 0 { 118 + m.selected = len(m.tasks) - 1 119 + } 120 + case taskViewMsg: 121 + m.viewContent = string(msg) 122 + m.viewing = true 123 + case errorTaskMsg: 124 + m.err = error(msg) 125 + } 126 + return m, nil 127 + } 128 + 129 + func (m taskListModel) View() string { 130 + var s strings.Builder 131 + 132 + style := lipgloss.NewStyle().Foreground(lipgloss.Color("86")) 133 + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) 134 + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true) 135 + headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) 136 + priorityHighStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) 137 + priorityMediumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")) 138 + priorityLowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("244")) 139 + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("28")) 140 + 141 + if m.viewing { 142 + s.WriteString(m.viewContent) 143 + s.WriteString("\n\n") 144 + s.WriteString(style.Render("Press q/esc/backspace to return to list")) 145 + return s.String() 146 + } 147 + 148 + s.WriteString(titleStyle.Render("Tasks")) 149 + if m.showAll { 150 + s.WriteString(" (showing all)") 151 + } else { 152 + s.WriteString(" (pending only)") 153 + } 154 + s.WriteString("\n\n") 155 + 156 + if m.err != nil { 157 + s.WriteString(fmt.Sprintf("Error: %s", m.err)) 158 + return s.String() 159 + } 160 + 161 + if len(m.tasks) == 0 { 162 + s.WriteString("No tasks found") 163 + s.WriteString("\n\n") 164 + s.WriteString(style.Render("Press r to refresh, q to quit")) 165 + return s.String() 166 + } 167 + 168 + headerLine := fmt.Sprintf("%-3s %-4s %-40s %-10s %-10s %-15s", "", "ID", "Description", "Status", "Priority", "Project") 169 + s.WriteString(headerStyle.Render(headerLine)) 170 + s.WriteString("\n") 171 + s.WriteString(headerStyle.Render(strings.Repeat("โ”€", 80))) 172 + s.WriteString("\n") 173 + 174 + for i, task := range m.tasks { 175 + prefix := " " 176 + if i == m.selected { 177 + prefix = " > " 178 + } 179 + 180 + description := task.Description 181 + if len(description) > 38 { 182 + description = description[:35] + "..." 183 + } 184 + 185 + status := task.Status 186 + if len(status) > 8 { 187 + status = status[:8] 188 + } 189 + 190 + priority := task.Priority 191 + if priority == "" { 192 + priority = "-" 193 + } 194 + if len(priority) > 8 { 195 + priority = priority[:8] 196 + } 197 + 198 + project := task.Project 199 + if project == "" { 200 + project = "-" 201 + } 202 + if len(project) > 13 { 203 + project = project[:10] + "..." 204 + } 205 + 206 + line := fmt.Sprintf("%s%-4d %-40s %-10s %-10s %-15s", 207 + prefix, task.ID, description, status, priority, project) 208 + 209 + if i == m.selected { 210 + s.WriteString(selectedStyle.Render(line)) 211 + } else { 212 + // Color based on priority 213 + switch strings.ToLower(task.Priority) { 214 + case "high", "urgent": 215 + s.WriteString(priorityHighStyle.Render(line)) 216 + case "medium": 217 + s.WriteString(priorityMediumStyle.Render(line)) 218 + case "low": 219 + s.WriteString(priorityLowStyle.Render(line)) 220 + default: 221 + if task.Status == "completed" { 222 + s.WriteString(statusStyle.Render(line)) 223 + } else { 224 + s.WriteString(style.Render(line)) 225 + } 226 + } 227 + } 228 + 229 + // Add tags if any 230 + if len(task.Tags) > 0 && i == m.selected { 231 + s.WriteString(" @" + strings.Join(task.Tags, " @")) 232 + } 233 + 234 + s.WriteString("\n") 235 + } 236 + 237 + s.WriteString("\n") 238 + s.WriteString(style.Render("Controls: โ†‘/โ†“/k/j to navigate, Enter/v to view, d to mark done, a to toggle all/pending")) 239 + s.WriteString("\n") 240 + s.WriteString(style.Render("r to refresh, q to quit, 1-9 to jump to task")) 241 + 242 + return s.String() 243 + } 244 + 245 + func (m taskListModel) loadTasks() tea.Cmd { 246 + return func() tea.Msg { 247 + opts := repo.TaskListOptions{} 248 + 249 + // Set status filter 250 + if m.showAll || m.opts.ShowAll { 251 + // Show all tasks - no status filter 252 + } else { 253 + opts.Status = "pending" 254 + } 255 + 256 + // Apply other filters from options 257 + if m.opts.Status != "" { 258 + opts.Status = m.opts.Status 259 + } 260 + if m.opts.Priority != "" { 261 + opts.Priority = m.opts.Priority 262 + } 263 + if m.opts.Project != "" { 264 + opts.Project = m.opts.Project 265 + } 266 + 267 + opts.SortBy = "modified" 268 + opts.SortOrder = "DESC" 269 + opts.Limit = 50 270 + 271 + tasks, err := m.repo.List(context.Background(), opts) 272 + if err != nil { 273 + return errorTaskMsg(err) 274 + } 275 + 276 + return tasksLoadedMsg(tasks) 277 + } 278 + } 279 + 280 + func (m taskListModel) viewTask(task *models.Task) tea.Cmd { 281 + return func() tea.Msg { 282 + var content strings.Builder 283 + 284 + content.WriteString(fmt.Sprintf("# Task %d\n\n", task.ID)) 285 + content.WriteString(fmt.Sprintf("**UUID:** %s\n", task.UUID)) 286 + content.WriteString(fmt.Sprintf("**Description:** %s\n", task.Description)) 287 + content.WriteString(fmt.Sprintf("**Status:** %s\n", task.Status)) 288 + 289 + if task.Priority != "" { 290 + content.WriteString(fmt.Sprintf("**Priority:** %s\n", task.Priority)) 291 + } 292 + 293 + if task.Project != "" { 294 + content.WriteString(fmt.Sprintf("**Project:** %s\n", task.Project)) 295 + } 296 + 297 + if len(task.Tags) > 0 { 298 + content.WriteString(fmt.Sprintf("**Tags:** %s\n", strings.Join(task.Tags, ", "))) 299 + } 300 + 301 + if task.Due != nil { 302 + content.WriteString(fmt.Sprintf("**Due:** %s\n", task.Due.Format("2006-01-02 15:04"))) 303 + } 304 + 305 + content.WriteString(fmt.Sprintf("**Created:** %s\n", task.Entry.Format("2006-01-02 15:04"))) 306 + content.WriteString(fmt.Sprintf("**Modified:** %s\n", task.Modified.Format("2006-01-02 15:04"))) 307 + 308 + if task.Start != nil { 309 + content.WriteString(fmt.Sprintf("**Started:** %s\n", task.Start.Format("2006-01-02 15:04"))) 310 + } 311 + 312 + if task.End != nil { 313 + content.WriteString(fmt.Sprintf("**Completed:** %s\n", task.End.Format("2006-01-02 15:04"))) 314 + } 315 + 316 + if len(task.Annotations) > 0 { 317 + content.WriteString("\n**Annotations:**\n") 318 + for _, annotation := range task.Annotations { 319 + content.WriteString(fmt.Sprintf("- %s\n", annotation)) 320 + } 321 + } 322 + 323 + return taskViewMsg(content.String()) 324 + } 325 + } 326 + 327 + func (m taskListModel) markDone(task *models.Task) tea.Cmd { 328 + return func() tea.Msg { 329 + if task.Status == "completed" { 330 + return errorTaskMsg(fmt.Errorf("task already completed")) 331 + } 332 + 333 + task.Status = "completed" 334 + err := m.repo.Update(context.Background(), task) 335 + if err != nil { 336 + return errorTaskMsg(fmt.Errorf("failed to mark task done: %w", err)) 337 + } 338 + 339 + // Reload tasks after marking done 340 + return m.loadTasks()() 341 + } 342 + } 343 + 344 + // Browse opens an interactive TUI for navigating and viewing tasks 345 + func (tl *TaskList) Browse(ctx context.Context) error { 346 + if tl.opts.Static { 347 + return tl.staticList(ctx) 348 + } 349 + 350 + model := taskListModel{ 351 + repo: tl.repo, 352 + opts: tl.opts, 353 + showAll: tl.opts.ShowAll, 354 + } 355 + 356 + program := tea.NewProgram(model, tea.WithInput(tl.opts.Input), tea.WithOutput(tl.opts.Output)) 357 + 358 + _, err := program.Run() 359 + return err 360 + } 361 + 362 + func (tl *TaskList) staticList(ctx context.Context) error { 363 + opts := repo.TaskListOptions{} 364 + 365 + if tl.opts.ShowAll { 366 + // Show all tasks - no status filter 367 + } else { 368 + opts.Status = "pending" 369 + } 370 + 371 + if tl.opts.Status != "" { 372 + opts.Status = tl.opts.Status 373 + } 374 + if tl.opts.Priority != "" { 375 + opts.Priority = tl.opts.Priority 376 + } 377 + if tl.opts.Project != "" { 378 + opts.Project = tl.opts.Project 379 + } 380 + 381 + opts.SortBy = "modified" 382 + opts.SortOrder = "DESC" 383 + 384 + tasks, err := tl.repo.List(ctx, opts) 385 + if err != nil { 386 + fmt.Fprintf(tl.opts.Output, "Error: %s\n", err) 387 + return err 388 + } 389 + 390 + fmt.Fprintf(tl.opts.Output, "Tasks") 391 + if tl.opts.ShowAll { 392 + fmt.Fprintf(tl.opts.Output, " (showing all)") 393 + } else { 394 + fmt.Fprintf(tl.opts.Output, " (pending only)") 395 + } 396 + fmt.Fprintf(tl.opts.Output, "\n\n") 397 + 398 + if len(tasks) == 0 { 399 + fmt.Fprintf(tl.opts.Output, "No tasks found\n") 400 + return nil 401 + } 402 + 403 + fmt.Fprintf(tl.opts.Output, "%-4s %-40s %-10s %-10s %-15s\n", "ID", "Description", "Status", "Priority", "Project") 404 + fmt.Fprintf(tl.opts.Output, "%s\n", strings.Repeat("โ”€", 80)) 405 + 406 + for _, task := range tasks { 407 + description := task.Description 408 + if len(description) > 38 { 409 + description = description[:35] + "..." 410 + } 411 + 412 + status := task.Status 413 + if len(status) > 8 { 414 + status = status[:8] 415 + } 416 + 417 + priority := task.Priority 418 + if priority == "" { 419 + priority = "-" 420 + } 421 + if len(priority) > 8 { 422 + priority = priority[:8] 423 + } 424 + 425 + project := task.Project 426 + if project == "" { 427 + project = "-" 428 + } 429 + if len(project) > 13 { 430 + project = project[:10] + "..." 431 + } 432 + 433 + fmt.Fprintf(tl.opts.Output, "%-4d %-40s %-10s %-10s %-15s", task.ID, description, status, priority, project) 434 + 435 + if len(task.Tags) > 0 { 436 + fmt.Fprintf(tl.opts.Output, " @%s", strings.Join(task.Tags, " @")) 437 + } 438 + 439 + fmt.Fprintf(tl.opts.Output, "\n") 440 + } 441 + 442 + return nil 443 + }
+878
internal/ui/task_list_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "errors" 7 + "fmt" 8 + "strings" 9 + "testing" 10 + "time" 11 + 12 + tea "github.com/charmbracelet/bubbletea" 13 + "github.com/stormlightlabs/noteleaf/internal/models" 14 + "github.com/stormlightlabs/noteleaf/internal/repo" 15 + ) 16 + 17 + // MockTaskRepository implements TaskRepository interface for testing 18 + type MockTaskRepository struct { 19 + tasks []*models.Task 20 + listError error 21 + updateError error 22 + } 23 + 24 + func (m *MockTaskRepository) List(ctx context.Context, opts repo.TaskListOptions) ([]*models.Task, error) { 25 + if m.listError != nil { 26 + return nil, m.listError 27 + } 28 + 29 + var filteredTasks []*models.Task 30 + for _, task := range m.tasks { 31 + // Apply filters 32 + if opts.Status != "" && task.Status != opts.Status { 33 + continue 34 + } 35 + if opts.Priority != "" && task.Priority != opts.Priority { 36 + continue 37 + } 38 + if opts.Project != "" && task.Project != opts.Project { 39 + continue 40 + } 41 + if opts.Search != "" && !strings.Contains(strings.ToLower(task.Description), strings.ToLower(opts.Search)) { 42 + continue 43 + } 44 + filteredTasks = append(filteredTasks, task) 45 + } 46 + 47 + // Apply limit 48 + if opts.Limit > 0 && len(filteredTasks) > opts.Limit { 49 + filteredTasks = filteredTasks[:opts.Limit] 50 + } 51 + 52 + return filteredTasks, nil 53 + } 54 + 55 + func (m *MockTaskRepository) Update(ctx context.Context, task *models.Task) error { 56 + if m.updateError != nil { 57 + return m.updateError 58 + } 59 + // Update the task in our mock data 60 + for i, t := range m.tasks { 61 + if t.ID == task.ID { 62 + m.tasks[i] = task 63 + break 64 + } 65 + } 66 + return nil 67 + } 68 + 69 + // Create mock tasks for testing 70 + func createMockTasks() []*models.Task { 71 + now := time.Now() 72 + return []*models.Task{ 73 + { 74 + ID: 1, 75 + UUID: "uuid-1", 76 + Description: "Review quarterly report", 77 + Status: "pending", 78 + Priority: "high", 79 + Project: "work", 80 + Tags: []string{"urgent", "business"}, 81 + Entry: now.Add(-24 * time.Hour), 82 + Modified: now.Add(-12 * time.Hour), 83 + }, 84 + { 85 + ID: 2, 86 + UUID: "uuid-2", 87 + Description: "Plan vacation itinerary", 88 + Status: "pending", 89 + Priority: "medium", 90 + Project: "personal", 91 + Tags: []string{"travel"}, 92 + Entry: now.Add(-48 * time.Hour), 93 + Modified: now.Add(-6 * time.Hour), 94 + }, 95 + { 96 + ID: 3, 97 + UUID: "uuid-3", 98 + Description: "Fix authentication bug", 99 + Status: "completed", 100 + Priority: "high", 101 + Project: "development", 102 + Tags: []string{"bug", "security"}, 103 + Entry: now.Add(-72 * time.Hour), 104 + Modified: now.Add(-1 * time.Hour), 105 + End: &now, 106 + }, 107 + { 108 + ID: 4, 109 + UUID: "uuid-4", 110 + Description: "Read Clean Code book", 111 + Status: "pending", 112 + Priority: "low", 113 + Project: "learning", 114 + Tags: []string{"books", "development"}, 115 + Entry: now.Add(-96 * time.Hour), 116 + Modified: now.Add(-3 * time.Hour), 117 + }, 118 + } 119 + } 120 + 121 + func TestTaskListOptions(t *testing.T) { 122 + t.Run("default options", func(t *testing.T) { 123 + opts := TaskListOptions{} 124 + if opts.Static { 125 + t.Error("Static should default to false") 126 + } 127 + if opts.ShowAll { 128 + t.Error("ShowAll should default to false") 129 + } 130 + }) 131 + 132 + t.Run("custom options", func(t *testing.T) { 133 + var buf bytes.Buffer 134 + opts := TaskListOptions{ 135 + Output: &buf, 136 + Static: true, 137 + ShowAll: true, 138 + Status: "pending", 139 + Priority: "high", 140 + Project: "work", 141 + } 142 + 143 + if !opts.Static { 144 + t.Error("Static should be enabled") 145 + } 146 + if !opts.ShowAll { 147 + t.Error("ShowAll should be enabled") 148 + } 149 + if opts.Output != &buf { 150 + t.Error("Output should be set to buffer") 151 + } 152 + if opts.Status != "pending" { 153 + t.Error("Status filter not set correctly") 154 + } 155 + if opts.Priority != "high" { 156 + t.Error("Priority filter not set correctly") 157 + } 158 + if opts.Project != "work" { 159 + t.Error("Project filter not set correctly") 160 + } 161 + }) 162 + } 163 + 164 + func TestNewTaskList(t *testing.T) { 165 + repo := &MockTaskRepository{tasks: createMockTasks()} 166 + 167 + t.Run("with default options", func(t *testing.T) { 168 + tl := NewTaskList(repo, TaskListOptions{}) 169 + if tl == nil { 170 + t.Fatal("NewTaskList returned nil") 171 + } 172 + if tl.repo != repo { 173 + t.Error("Repository not set correctly") 174 + } 175 + if tl.opts.Output == nil { 176 + t.Error("Output should default to os.Stdout") 177 + } 178 + if tl.opts.Input == nil { 179 + t.Error("Input should default to os.Stdin") 180 + } 181 + }) 182 + 183 + t.Run("with custom options", func(t *testing.T) { 184 + var buf bytes.Buffer 185 + opts := TaskListOptions{ 186 + Output: &buf, 187 + Static: true, 188 + ShowAll: true, 189 + Priority: "high", 190 + } 191 + tl := NewTaskList(repo, opts) 192 + if tl.opts.Output != &buf { 193 + t.Error("Custom output not set") 194 + } 195 + if !tl.opts.Static { 196 + t.Error("Static mode not set") 197 + } 198 + if tl.opts.Priority != "high" { 199 + t.Error("Priority filter not set") 200 + } 201 + }) 202 + } 203 + 204 + func TestTaskListStaticMode(t *testing.T) { 205 + t.Run("successful static list", func(t *testing.T) { 206 + repo := &MockTaskRepository{tasks: createMockTasks()} 207 + var buf bytes.Buffer 208 + 209 + tl := NewTaskList(repo, TaskListOptions{ 210 + Output: &buf, 211 + Static: true, 212 + }) 213 + 214 + err := tl.Browse(context.Background()) 215 + if err != nil { 216 + t.Fatalf("Browse failed: %v", err) 217 + } 218 + 219 + output := buf.String() 220 + if !strings.Contains(output, "Tasks (pending only)") { 221 + t.Error("Title not displayed correctly") 222 + } 223 + if !strings.Contains(output, "Review quarterly report") { 224 + t.Error("First task not displayed") 225 + } 226 + if !strings.Contains(output, "Plan vacation itinerary") { 227 + t.Error("Second task not displayed") 228 + } 229 + // Should not show completed task by default 230 + if strings.Contains(output, "Fix authentication bug") { 231 + t.Error("Completed task should not be shown by default") 232 + } 233 + }) 234 + 235 + t.Run("static list with all tasks", func(t *testing.T) { 236 + repo := &MockTaskRepository{tasks: createMockTasks()} 237 + var buf bytes.Buffer 238 + 239 + tl := NewTaskList(repo, TaskListOptions{ 240 + Output: &buf, 241 + Static: true, 242 + ShowAll: true, 243 + }) 244 + 245 + err := tl.Browse(context.Background()) 246 + if err != nil { 247 + t.Fatalf("Browse failed: %v", err) 248 + } 249 + 250 + output := buf.String() 251 + if !strings.Contains(output, "Tasks (showing all)") { 252 + t.Error("All tasks title not displayed correctly") 253 + } 254 + if !strings.Contains(output, "Fix authentication bug") { 255 + t.Error("Completed task should be shown with --all") 256 + } 257 + }) 258 + 259 + t.Run("static list with filters", func(t *testing.T) { 260 + repo := &MockTaskRepository{tasks: createMockTasks()} 261 + var buf bytes.Buffer 262 + 263 + tl := NewTaskList(repo, TaskListOptions{ 264 + Output: &buf, 265 + Static: true, 266 + ShowAll: true, 267 + Priority: "high", 268 + }) 269 + 270 + err := tl.Browse(context.Background()) 271 + if err != nil { 272 + t.Fatalf("Browse failed: %v", err) 273 + } 274 + 275 + output := buf.String() 276 + if !strings.Contains(output, "Review quarterly report") { 277 + t.Error("High priority task not displayed") 278 + } 279 + if !strings.Contains(output, "Fix authentication bug") { 280 + t.Error("High priority completed task not displayed") 281 + } 282 + if strings.Contains(output, "Plan vacation itinerary") { 283 + t.Error("Medium priority task should not be displayed") 284 + } 285 + }) 286 + 287 + t.Run("static list with no results", func(t *testing.T) { 288 + repo := &MockTaskRepository{tasks: []*models.Task{}} 289 + var buf bytes.Buffer 290 + 291 + tl := NewTaskList(repo, TaskListOptions{ 292 + Output: &buf, 293 + Static: true, 294 + }) 295 + 296 + err := tl.Browse(context.Background()) 297 + if err != nil { 298 + t.Fatalf("Browse failed: %v", err) 299 + } 300 + 301 + output := buf.String() 302 + if !strings.Contains(output, "No tasks found") { 303 + t.Error("No tasks message not displayed") 304 + } 305 + }) 306 + 307 + t.Run("static list with repository error", func(t *testing.T) { 308 + repo := &MockTaskRepository{ 309 + listError: errors.New("database error"), 310 + } 311 + var buf bytes.Buffer 312 + 313 + tl := NewTaskList(repo, TaskListOptions{ 314 + Output: &buf, 315 + Static: true, 316 + }) 317 + 318 + err := tl.Browse(context.Background()) 319 + if err == nil { 320 + t.Fatal("Expected error, got nil") 321 + } 322 + 323 + output := buf.String() 324 + if !strings.Contains(output, "Error: database error") { 325 + t.Error("Error message not displayed") 326 + } 327 + }) 328 + } 329 + 330 + func TestTaskListModel(t *testing.T) { 331 + repo := &MockTaskRepository{tasks: createMockTasks()} 332 + 333 + t.Run("initial model state", func(t *testing.T) { 334 + model := taskListModel{ 335 + opts: TaskListOptions{ShowAll: false}, 336 + } 337 + 338 + if model.selected != 0 { 339 + t.Error("Initial selected should be 0") 340 + } 341 + if model.viewing { 342 + t.Error("Initial viewing should be false") 343 + } 344 + if model.showAll { 345 + t.Error("Initial showAll should be false") 346 + } 347 + }) 348 + 349 + t.Run("load tasks command", func(t *testing.T) { 350 + model := taskListModel{ 351 + repo: repo, 352 + opts: TaskListOptions{ShowAll: false}, 353 + } 354 + 355 + cmd := model.loadTasks() 356 + if cmd == nil { 357 + t.Fatal("loadTasks should return a command") 358 + } 359 + 360 + msg := cmd() 361 + switch msg := msg.(type) { 362 + case tasksLoadedMsg: 363 + tasks := []*models.Task(msg) 364 + if len(tasks) != 3 { // Only pending tasks 365 + t.Errorf("Expected 3 pending tasks, got %d", len(tasks)) 366 + } 367 + case errorTaskMsg: 368 + t.Fatalf("Unexpected error: %v", error(msg)) 369 + default: 370 + t.Fatalf("Unexpected message type: %T", msg) 371 + } 372 + }) 373 + 374 + t.Run("load all tasks", func(t *testing.T) { 375 + model := taskListModel{ 376 + repo: repo, 377 + opts: TaskListOptions{ShowAll: true}, 378 + showAll: true, 379 + } 380 + 381 + cmd := model.loadTasks() 382 + msg := cmd() 383 + 384 + switch msg := msg.(type) { 385 + case tasksLoadedMsg: 386 + tasks := []*models.Task(msg) 387 + if len(tasks) != 4 { // All tasks 388 + t.Errorf("Expected 4 tasks, got %d", len(tasks)) 389 + } 390 + case errorTaskMsg: 391 + t.Fatalf("Unexpected error: %v", error(msg)) 392 + } 393 + }) 394 + 395 + t.Run("view task command", func(t *testing.T) { 396 + model := taskListModel{ 397 + repo: repo, 398 + opts: TaskListOptions{}, 399 + } 400 + 401 + task := createMockTasks()[0] 402 + cmd := model.viewTask(task) 403 + if cmd == nil { 404 + t.Fatal("viewTask should return a command") 405 + } 406 + 407 + msg := cmd() 408 + switch msg := msg.(type) { 409 + case taskViewMsg: 410 + content := string(msg) 411 + if !strings.Contains(content, "# Task 1") { 412 + t.Error("Task title not in view content") 413 + } 414 + if !strings.Contains(content, "Review quarterly report") { 415 + t.Error("Task description not in view content") 416 + } 417 + if !strings.Contains(content, "**Status:** pending") { 418 + t.Error("Task status not in view content") 419 + } 420 + if !strings.Contains(content, "**Priority:** high") { 421 + t.Error("Task priority not in view content") 422 + } 423 + default: 424 + t.Fatalf("Unexpected message type: %T", msg) 425 + } 426 + }) 427 + 428 + t.Run("mark done command", func(t *testing.T) { 429 + model := taskListModel{ 430 + repo: repo, 431 + opts: TaskListOptions{}, 432 + } 433 + 434 + task := createMockTasks()[0] // Pending task 435 + cmd := model.markDone(task) 436 + if cmd == nil { 437 + t.Fatal("markDone should return a command") 438 + } 439 + 440 + msg := cmd() 441 + // Should return a loadTasks command after marking done 442 + switch msg := msg.(type) { 443 + case tasksLoadedMsg: 444 + // Success - tasks reloaded 445 + case errorTaskMsg: 446 + // Check if it's the expected error for already completed task 447 + err := error(msg) 448 + if !strings.Contains(err.Error(), "completed") { 449 + t.Fatalf("Unexpected error: %v", err) 450 + } 451 + default: 452 + t.Fatalf("Unexpected message type: %T", msg) 453 + } 454 + }) 455 + 456 + t.Run("mark done already completed task", func(t *testing.T) { 457 + model := taskListModel{ 458 + repo: repo, 459 + opts: TaskListOptions{}, 460 + } 461 + 462 + task := createMockTasks()[2] // Already completed task 463 + cmd := model.markDone(task) 464 + msg := cmd() 465 + 466 + switch msg := msg.(type) { 467 + case errorTaskMsg: 468 + err := error(msg) 469 + if !strings.Contains(err.Error(), "already completed") { 470 + t.Errorf("Expected 'already completed' error, got: %v", err) 471 + } 472 + default: 473 + t.Fatalf("Expected errorTaskMsg for already completed task, got: %T", msg) 474 + } 475 + }) 476 + } 477 + 478 + func TestTaskListModelKeyHandling(t *testing.T) { 479 + repo := &MockTaskRepository{tasks: createMockTasks()} 480 + 481 + t.Run("quit commands", func(t *testing.T) { 482 + model := taskListModel{ 483 + repo: repo, 484 + tasks: createMockTasks()[:2], // First 2 tasks 485 + opts: TaskListOptions{}, 486 + } 487 + 488 + quitKeys := []string{"ctrl+c", "q"} 489 + for _, key := range quitKeys { 490 + newModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) 491 + if cmd == nil { 492 + t.Errorf("Key %s should return quit command", key) 493 + } 494 + _ = newModel // Model should be returned 495 + } 496 + }) 497 + 498 + t.Run("navigation keys", func(t *testing.T) { 499 + model := taskListModel{ 500 + repo: repo, 501 + tasks: createMockTasks()[:3], // First 3 tasks 502 + selected: 1, // Start in middle 503 + opts: TaskListOptions{}, 504 + } 505 + 506 + // Test up navigation 507 + upKeys := []string{"up", "k"} 508 + for _, key := range upKeys { 509 + testModel := model 510 + newModel, _ := testModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) 511 + if m, ok := newModel.(taskListModel); ok { 512 + if m.selected != 0 { 513 + t.Errorf("Key %s should move selection up to 0, got %d", key, m.selected) 514 + } 515 + } 516 + } 517 + 518 + // Test down navigation 519 + downKeys := []string{"down", "j"} 520 + for _, key := range downKeys { 521 + testModel := model 522 + newModel, _ := testModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) 523 + if m, ok := newModel.(taskListModel); ok { 524 + if m.selected != 2 { 525 + t.Errorf("Key %s should move selection down to 2, got %d", key, m.selected) 526 + } 527 + } 528 + } 529 + }) 530 + 531 + t.Run("view task keys", func(t *testing.T) { 532 + model := taskListModel{ 533 + repo: repo, 534 + tasks: createMockTasks()[:2], 535 + selected: 0, 536 + opts: TaskListOptions{}, 537 + } 538 + 539 + viewKeys := []string{"enter", "v"} 540 + for _, key := range viewKeys { 541 + testModel := model 542 + _, cmd := testModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) 543 + if cmd == nil { 544 + t.Errorf("Key %s should return view command", key) 545 + } 546 + } 547 + }) 548 + 549 + t.Run("number shortcuts", func(t *testing.T) { 550 + model := taskListModel{ 551 + repo: repo, 552 + tasks: createMockTasks()[:4], 553 + opts: TaskListOptions{}, 554 + } 555 + 556 + for i := 1; i <= 4; i++ { 557 + testModel := model 558 + key := fmt.Sprintf("%d", i) 559 + newModel, _ := testModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) 560 + if m, ok := newModel.(taskListModel); ok { 561 + expectedIndex := i - 1 562 + if m.selected != expectedIndex { 563 + t.Errorf("Number key %s should select index %d, got %d", key, expectedIndex, m.selected) 564 + } 565 + } 566 + } 567 + }) 568 + 569 + t.Run("toggle all/pending", func(t *testing.T) { 570 + model := taskListModel{ 571 + repo: repo, 572 + tasks: createMockTasks()[:2], 573 + showAll: false, 574 + opts: TaskListOptions{}, 575 + } 576 + 577 + newModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) 578 + if m, ok := newModel.(taskListModel); ok { 579 + if !m.showAll { 580 + t.Error("Key 'a' should toggle showAll to true") 581 + } 582 + } 583 + if cmd == nil { 584 + t.Error("Toggle all should trigger task reload") 585 + } 586 + }) 587 + 588 + t.Run("mark done key", func(t *testing.T) { 589 + model := taskListModel{ 590 + repo: repo, 591 + tasks: createMockTasks()[:2], 592 + selected: 0, 593 + opts: TaskListOptions{}, 594 + } 595 + 596 + _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) 597 + if cmd == nil { 598 + t.Error("Key 'd' should return mark done command") 599 + } 600 + }) 601 + 602 + t.Run("refresh key", func(t *testing.T) { 603 + model := taskListModel{ 604 + repo: repo, 605 + tasks: createMockTasks()[:2], 606 + opts: TaskListOptions{}, 607 + } 608 + 609 + _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) 610 + if cmd == nil { 611 + t.Error("Key 'r' should return refresh command") 612 + } 613 + }) 614 + 615 + t.Run("viewing mode navigation", func(t *testing.T) { 616 + model := taskListModel{ 617 + repo: repo, 618 + tasks: createMockTasks()[:2], 619 + viewing: true, 620 + viewContent: "Test content", 621 + opts: TaskListOptions{}, 622 + } 623 + 624 + exitKeys := []string{"q", "esc", "backspace"} 625 + for _, key := range exitKeys { 626 + testModel := model 627 + newModel, _ := testModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) 628 + if m, ok := newModel.(taskListModel); ok { 629 + if m.viewing { 630 + t.Errorf("Key %s should exit viewing mode", key) 631 + } 632 + if m.viewContent != "" { 633 + t.Errorf("Key %s should clear view content", key) 634 + } 635 + } 636 + } 637 + }) 638 + } 639 + 640 + func TestTaskListModelView(t *testing.T) { 641 + repo := &MockTaskRepository{tasks: createMockTasks()} 642 + 643 + t.Run("viewing mode", func(t *testing.T) { 644 + model := taskListModel{ 645 + repo: repo, 646 + viewing: true, 647 + viewContent: "# Task Details\nTest content here", 648 + opts: TaskListOptions{}, 649 + } 650 + 651 + view := model.View() 652 + if !strings.Contains(view, "# Task Details") { 653 + t.Error("View content not displayed in viewing mode") 654 + } 655 + if !strings.Contains(view, "Press q/esc/backspace to return to list") { 656 + t.Error("Return instructions not displayed") 657 + } 658 + }) 659 + 660 + t.Run("error state", func(t *testing.T) { 661 + model := taskListModel{ 662 + repo: repo, 663 + err: errors.New("test error"), 664 + opts: TaskListOptions{}, 665 + } 666 + 667 + view := model.View() 668 + if !strings.Contains(view, "Error: test error") { 669 + t.Error("Error message not displayed") 670 + } 671 + }) 672 + 673 + t.Run("no tasks", func(t *testing.T) { 674 + model := taskListModel{ 675 + repo: repo, 676 + tasks: []*models.Task{}, 677 + opts: TaskListOptions{}, 678 + } 679 + 680 + view := model.View() 681 + if !strings.Contains(view, "No tasks found") { 682 + t.Error("No tasks message not displayed") 683 + } 684 + if !strings.Contains(view, "Press r to refresh, q to quit") { 685 + t.Error("Help text not displayed") 686 + } 687 + }) 688 + 689 + t.Run("with tasks", func(t *testing.T) { 690 + tasks := createMockTasks()[:2] // First 2 tasks 691 + model := taskListModel{ 692 + repo: repo, 693 + tasks: tasks, 694 + selected: 0, 695 + showAll: false, 696 + opts: TaskListOptions{}, 697 + } 698 + 699 + view := model.View() 700 + if !strings.Contains(view, "Tasks (pending only)") { 701 + t.Error("Title not displayed correctly") 702 + } 703 + if !strings.Contains(view, "Review quarterly report") { 704 + t.Error("First task not displayed") 705 + } 706 + if !strings.Contains(view, "Plan vacation itinerary") { 707 + t.Error("Second task not displayed") 708 + } 709 + if !strings.Contains(view, "Controls:") { 710 + t.Error("Control instructions not displayed") 711 + } 712 + }) 713 + 714 + t.Run("show all mode", func(t *testing.T) { 715 + model := taskListModel{ 716 + repo: repo, 717 + tasks: createMockTasks(), 718 + showAll: true, 719 + opts: TaskListOptions{ShowAll: true}, 720 + } 721 + 722 + view := model.View() 723 + if !strings.Contains(view, "Tasks (showing all)") { 724 + t.Error("Show all title not displayed correctly") 725 + } 726 + }) 727 + 728 + t.Run("selected task highlighting", func(t *testing.T) { 729 + tasks := createMockTasks()[:2] 730 + model := taskListModel{ 731 + repo: repo, 732 + tasks: tasks, 733 + selected: 0, 734 + opts: TaskListOptions{}, 735 + } 736 + 737 + view := model.View() 738 + // The selected task should have a ">" prefix 739 + if !strings.Contains(view, " > 1 ") { 740 + t.Error("Selected task not highlighted with '>' prefix") 741 + } 742 + }) 743 + } 744 + 745 + func TestTaskListModelUpdate(t *testing.T) { 746 + repo := &MockTaskRepository{tasks: createMockTasks()} 747 + 748 + t.Run("tasks loaded message", func(t *testing.T) { 749 + model := taskListModel{ 750 + repo: repo, 751 + opts: TaskListOptions{}, 752 + } 753 + 754 + tasks := createMockTasks()[:2] 755 + newModel, _ := model.Update(tasksLoadedMsg(tasks)) 756 + 757 + if m, ok := newModel.(taskListModel); ok { 758 + if len(m.tasks) != 2 { 759 + t.Errorf("Expected 2 tasks, got %d", len(m.tasks)) 760 + } 761 + if m.tasks[0].Description != "Review quarterly report" { 762 + t.Error("Tasks not loaded correctly") 763 + } 764 + } 765 + }) 766 + 767 + t.Run("task view message", func(t *testing.T) { 768 + model := taskListModel{ 769 + repo: repo, 770 + opts: TaskListOptions{}, 771 + } 772 + 773 + content := "# Task Details\nTest content" 774 + newModel, _ := model.Update(taskViewMsg(content)) 775 + 776 + if m, ok := newModel.(taskListModel); ok { 777 + if !m.viewing { 778 + t.Error("Viewing mode not activated") 779 + } 780 + if m.viewContent != content { 781 + t.Error("View content not set correctly") 782 + } 783 + } 784 + }) 785 + 786 + t.Run("error message", func(t *testing.T) { 787 + model := taskListModel{ 788 + repo: repo, 789 + opts: TaskListOptions{}, 790 + } 791 + 792 + testErr := errors.New("test error") 793 + newModel, _ := model.Update(errorTaskMsg(testErr)) 794 + 795 + if m, ok := newModel.(taskListModel); ok { 796 + if m.err == nil { 797 + t.Error("Error not set") 798 + } 799 + if m.err.Error() != "test error" { 800 + t.Errorf("Expected 'test error', got %v", m.err) 801 + } 802 + } 803 + }) 804 + 805 + t.Run("selected index bounds", func(t *testing.T) { 806 + model := taskListModel{ 807 + repo: repo, 808 + tasks: createMockTasks()[:2], 809 + selected: 5, // Out of bounds 810 + opts: TaskListOptions{}, 811 + } 812 + 813 + // Load fewer tasks 814 + newTasks := createMockTasks()[:1] 815 + newModel, _ := model.Update(tasksLoadedMsg(newTasks)) 816 + 817 + if m, ok := newModel.(taskListModel); ok { 818 + if m.selected >= len(m.tasks) { 819 + t.Error("Selected index should be adjusted to bounds") 820 + } 821 + if m.selected != 0 { // Should be adjusted to last valid index 822 + t.Errorf("Expected selected to be 0, got %d", m.selected) 823 + } 824 + } 825 + }) 826 + } 827 + 828 + func TestTaskListRepositoryError(t *testing.T) { 829 + t.Run("list error in loadTasks", func(t *testing.T) { 830 + repo := &MockTaskRepository{ 831 + listError: errors.New("database connection failed"), 832 + } 833 + 834 + model := taskListModel{ 835 + repo: repo, 836 + opts: TaskListOptions{}, 837 + } 838 + 839 + cmd := model.loadTasks() 840 + msg := cmd() 841 + 842 + switch msg := msg.(type) { 843 + case errorTaskMsg: 844 + err := error(msg) 845 + if !strings.Contains(err.Error(), "database connection failed") { 846 + t.Errorf("Expected database error, got: %v", err) 847 + } 848 + default: 849 + t.Fatalf("Expected errorTaskMsg, got: %T", msg) 850 + } 851 + }) 852 + 853 + t.Run("update error in markDone", func(t *testing.T) { 854 + repo := &MockTaskRepository{ 855 + tasks: createMockTasks(), 856 + updateError: errors.New("update failed"), 857 + } 858 + 859 + model := taskListModel{ 860 + repo: repo, 861 + opts: TaskListOptions{}, 862 + } 863 + 864 + task := createMockTasks()[0] 865 + cmd := model.markDone(task) 866 + msg := cmd() 867 + 868 + switch msg := msg.(type) { 869 + case errorTaskMsg: 870 + err := error(msg) 871 + if !strings.Contains(err.Error(), "failed to mark task done") { 872 + t.Errorf("Expected mark done error, got: %v", err) 873 + } 874 + default: 875 + t.Fatalf("Expected errorTaskMsg, got: %T", msg) 876 + } 877 + }) 878 + }