diff --git a/cmd/task/list.go b/cmd/task/list.go index 9523a7d2a5a3ec2d97fe739d0d91e687df1c67ed..8b482c7dec4852c1c54e4aa604bd761fb5ded5aa 100644 --- a/cmd/task/list.go +++ b/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 +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 2c616e2a77251e727c6a3b60a13b97cbed8d0ee2..4f8ad59a05ba5c342fe9c03cfdc79ad3c553785e 100644 --- a/internal/ui/styles.go +++ b/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) +}