feat(task): implement list command

Amolith created

- Default filter: incomplete tasks + completed today
- --all flag to show full history
- --area and --status filters
- JSON and table output
- Locale-aware date formatting via ui.FormatDate

Assisted-by: Claude Opus 4.5 via Crush

Change summary

cmd/task/list.go      | 184 +++++++++++++++++++++++++++++++++++++++++++-
internal/ui/styles.go |  33 +++++++
2 files changed, 210 insertions(+), 7 deletions(-)

Detailed changes

cmd/task/list.go 🔗

@@ -5,34 +5,206 @@
 package task
 
 import (
+	"encoding/json"
+	"errors"
 	"fmt"
+	"text/tabwriter"
+	"time"
 
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/completion"
+	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/ui"
 	"github.com/spf13/cobra"
 )
 
+// ErrUnknownArea indicates the specified area key was not found in config.
+var ErrUnknownArea = errors.New("unknown area key")
+
 // ListCmd lists tasks. Exported for potential use by shortcuts.
 var ListCmd = &cobra.Command{
 	Use:   "list",
 	Short: "List tasks",
 	Long: `List tasks from Lunatask.
 
+By default, shows incomplete tasks and tasks completed today.
+Use --all to show entire task history.
+
 Note: Due to end-to-end encryption, task names and notes
 are not available through the API. Only metadata is shown.`,
-	RunE: func(cmd *cobra.Command, _ []string) error {
-		// TODO: implement task listing
-		fmt.Fprintln(cmd.OutOrStdout(), "Task listing not yet implemented")
-
-		return nil
-	},
+	RunE: runList,
 }
 
 func init() {
 	ListCmd.Flags().StringP("area", "a", "", "Filter by area key")
 	ListCmd.Flags().StringP("status", "s", "", "Filter by status")
+	ListCmd.Flags().Bool("all", false, "Show all tasks including completed")
 	ListCmd.Flags().Bool("json", false, "Output as JSON")
 
 	_ = ListCmd.RegisterFlagCompletionFunc("area", completion.Areas)
 	_ = ListCmd.RegisterFlagCompletionFunc("status",
 		completion.Static("later", "next", "started", "waiting", "completed"))
 }
+
+func runList(cmd *cobra.Command, _ []string) error {
+	apiClient, err := client.New()
+	if err != nil {
+		return err
+	}
+
+	tasks, err := apiClient.ListTasks(cmd.Context(), nil)
+	if err != nil {
+		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Failed to list tasks"))
+
+		return err
+	}
+
+	areaID, err := resolveAreaFilter(cmd)
+	if err != nil {
+		return err
+	}
+
+	statusFilter := mustGetStringFlag(cmd, "status")
+	showAll := mustGetBoolFlag(cmd, "all")
+	tasks = applyFilters(tasks, areaID, statusFilter, showAll)
+
+	if len(tasks) == 0 {
+		fmt.Fprintln(cmd.OutOrStdout(), ui.Muted.Render("No tasks found"))
+
+		return nil
+	}
+
+	if mustGetBoolFlag(cmd, "json") {
+		return outputJSON(cmd, tasks)
+	}
+
+	return outputTable(cmd, tasks)
+}
+
+// mustGetStringFlag returns the string flag value. Panics if flag doesn't exist
+// (indicates a programming error—flags are defined in init).
+func mustGetStringFlag(cmd *cobra.Command, name string) string {
+	f := cmd.Flags().Lookup(name)
+	if f == nil {
+		panic("flag not defined: " + name)
+	}
+
+	return f.Value.String()
+}
+
+// mustGetBoolFlag returns the bool flag value. Panics if flag doesn't exist.
+func mustGetBoolFlag(cmd *cobra.Command, name string) bool {
+	f := cmd.Flags().Lookup(name)
+	if f == nil {
+		panic("flag not defined: " + name)
+	}
+
+	return f.Value.String() == "true"
+}
+
+func resolveAreaFilter(cmd *cobra.Command) (string, error) {
+	areaKey := mustGetStringFlag(cmd, "area")
+	if areaKey == "" {
+		return "", nil
+	}
+
+	cfg, err := config.Load()
+	if err != nil {
+		if errors.Is(err, config.ErrNotFound) {
+			fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config not found; run 'lune init' to configure areas"))
+		}
+
+		return "", err
+	}
+
+	area := cfg.AreaByKey(areaKey)
+	if area == nil {
+		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Unknown area: "+areaKey))
+
+		return "", fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
+	}
+
+	return area.ID, nil
+}
+
+func applyFilters(tasks []lunatask.Task, areaID, statusFilter string, showAll bool) []lunatask.Task {
+	filtered := make([]lunatask.Task, 0, len(tasks))
+	today := time.Now().Truncate(24 * time.Hour)
+
+	for _, task := range tasks {
+		if !matchesFilters(task, areaID, statusFilter, showAll, today) {
+			continue
+		}
+
+		filtered = append(filtered, task)
+	}
+
+	return filtered
+}
+
+func matchesFilters(task lunatask.Task, areaID, statusFilter string, showAll bool, today time.Time) bool {
+	if areaID != "" && (task.AreaID == nil || *task.AreaID != areaID) {
+		return false
+	}
+
+	if statusFilter != "" && (task.Status == nil || string(*task.Status) != statusFilter) {
+		return false
+	}
+
+	// Default filter: exclude completed tasks unless completed today
+	if !showAll && statusFilter == "" && isOldCompleted(task, today) {
+		return false
+	}
+
+	return true
+}
+
+func isOldCompleted(task lunatask.Task, today time.Time) bool {
+	if task.Status == nil || *task.Status != lunatask.StatusCompleted {
+		return false
+	}
+
+	return task.CompletedAt == nil || task.CompletedAt.Before(today)
+}
+
+func outputJSON(cmd *cobra.Command, tasks []lunatask.Task) error {
+	enc := json.NewEncoder(cmd.OutOrStdout())
+	enc.SetIndent("", "  ")
+
+	if err := enc.Encode(tasks); err != nil {
+		return fmt.Errorf("encoding JSON: %w", err)
+	}
+
+	return nil
+}
+
+func outputTable(cmd *cobra.Command, tasks []lunatask.Task) error {
+	writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
+
+	header := ui.Bold.Render("ID") + "\t" + ui.Bold.Render("STATUS") + "\t" +
+		ui.Bold.Render("SCHEDULED") + "\t" + ui.Bold.Render("CREATED")
+	fmt.Fprintln(writer, header)
+
+	for _, task := range tasks {
+		status := "-"
+		if task.Status != nil {
+			status = string(*task.Status)
+		}
+
+		scheduled := "-"
+		if task.ScheduledOn != nil {
+			scheduled = task.ScheduledOn.String()
+		}
+
+		created := ui.FormatDate(task.CreatedAt)
+
+		fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", task.ID, status, scheduled, created)
+	}
+
+	if err := writer.Flush(); err != nil {
+		return fmt.Errorf("writing output: %w", err)
+	}
+
+	return nil
+}

internal/ui/styles.go 🔗

@@ -5,7 +5,12 @@
 // Package ui provides lipgloss styles for terminal output.
 package ui
 
-import "github.com/charmbracelet/lipgloss"
+import (
+	"os"
+	"time"
+
+	"github.com/charmbracelet/lipgloss"
+)
 
 // Terminal output styles using ANSI colors for broad compatibility.
 var (
@@ -15,3 +20,29 @@ var (
 	Muted   = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray
 	Bold    = lipgloss.NewStyle().Bold(true)
 )
+
+// dateFormat holds the date format string derived from locale.
+var dateFormat = initDateFormat()
+
+// initDateFormat determines the date format based on LC_TIME or LANG.
+func initDateFormat() string {
+	locale := os.Getenv("LC_TIME")
+	if locale == "" {
+		locale = os.Getenv("LANG")
+	}
+
+	// US English uses month-first; most other locales use day-first or ISO
+	switch {
+	case len(locale) >= 2 && locale[:2] == "en" && len(locale) >= 5 && locale[3:5] == "US":
+		return "01/02/2006"
+	case len(locale) >= 2 && locale[:2] == "en":
+		return "02/01/2006"
+	default:
+		return "2006-01-02" // ISO 8601 as sensible default
+	}
+}
+
+// FormatDate formats a time.Time as a date string using the user's locale.
+func FormatDate(t time.Time) string {
+	return t.Format(dateFormat)
+}