From cc25d60a154ef24bfe888b44c8fdb0f933d7d4fb Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 21 Dec 2025 18:06:24 -0700 Subject: [PATCH] feat(area): add list and show commands - list: table/JSON output with goal preview - show: full details with ID, deeplink, goals, task counts Uses H1/H2 headings for visual hierarchy. Assisted-by: Claude Opus 4.5 via Crush --- cmd/area/area.go | 27 +++++++++++ cmd/area/list.go | 120 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/area/show.go | 102 ++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 2 + 4 files changed, 251 insertions(+) create mode 100644 cmd/area/area.go create mode 100644 cmd/area/list.go create mode 100644 cmd/area/show.go diff --git a/cmd/area/area.go b/cmd/area/area.go new file mode 100644 index 0000000000000000000000000000000000000000..3fe6f6f651e5d549e6829f4dc85e722a97a7b1fd --- /dev/null +++ b/cmd/area/area.go @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package area provides commands for viewing configured areas. +package area + +import ( + "errors" + + "github.com/spf13/cobra" +) + +// ErrUnknownArea indicates the specified area key was not found in config. +var ErrUnknownArea = errors.New("unknown area key") + +// Cmd is the parent command for area operations. +var Cmd = &cobra.Command{ + Use: "area", + Short: "View configured areas", + GroupID: "resources", +} + +func init() { + Cmd.AddCommand(ListCmd) + Cmd.AddCommand(ShowCmd) +} diff --git a/cmd/area/list.go b/cmd/area/list.go new file mode 100644 index 0000000000000000000000000000000000000000..b25a5b3d341c265b13cc27fe16a0f856ab5b0c5b --- /dev/null +++ b/cmd/area/list.go @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package area + +import ( + "encoding/json" + "fmt" + + "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/ui" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/spf13/cobra" +) + +// ListCmd lists configured areas and their goals. +var ListCmd = &cobra.Command{ + Use: "list", + Short: "List configured areas", + Long: `List areas configured in lune. + +Areas are copied from the Lunatask desktop app during 'lune init'. +Each area may have goals associated with it.`, + RunE: runList, +} + +func init() { + ListCmd.Flags().Bool("json", false, "Output as JSON") +} + +func runList(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config not found; run 'lune init' to configure areas")) + + return err + } + + if len(cfg.Areas) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No areas configured") + + return nil + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + return outputJSON(cmd, cfg.Areas) + } + + return outputTable(cmd, cfg.Areas) +} + +func outputJSON(cmd *cobra.Command, areas []config.Area) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + + if err := enc.Encode(areas); err != nil { + return fmt.Errorf("encoding JSON: %w", err) + } + + return nil +} + +func outputTable(cmd *cobra.Command, areas []config.Area) error { + rows := make([][]string, 0, len(areas)) + + for _, area := range areas { + goals := formatGoals(area.Goals) + rows = append(rows, []string{area.Key, area.Name, goals}) + } + + tbl := table.New(). + Headers("KEY", "NAME", "GOALS"). + Rows(rows...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return ui.Bold + } + + return lipgloss.NewStyle() + }). + Border(lipgloss.HiddenBorder()) + + fmt.Fprintln(cmd.OutOrStdout(), tbl.Render()) + + return nil +} + +func formatGoals(goals []config.Goal) string { + if len(goals) == 0 { + return "-" + } + + keys := make([]string, len(goals)) + for i, g := range goals { + keys[i] = g.Key + } + + if len(keys) <= 3 { + return joinKeys(keys) + } + + return joinKeys(keys[:3]) + fmt.Sprintf(" (+%d)", len(keys)-3) +} + +func joinKeys(keys []string) string { + result := "" + + for i, k := range keys { + if i > 0 { + result += ", " + } + + result += k + } + + return result +} diff --git a/cmd/area/show.go b/cmd/area/show.go new file mode 100644 index 0000000000000000000000000000000000000000..627d80b7230aa5e97ca1f2e98ea7ca3d4152a073 --- /dev/null +++ b/cmd/area/show.go @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package area + +import ( + "fmt" + + "git.secluded.site/lune/internal/client" + "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/deeplink" + "git.secluded.site/lune/internal/stats" + "git.secluded.site/lune/internal/ui" + "github.com/spf13/cobra" +) + +// ShowCmd displays details for a specific area. +var ShowCmd = &cobra.Command{ + Use: "show KEY", + Short: "Show area details", + Long: `Show detailed information for a configured area. + +Displays the area's name, ID, deeplink, goals, and uncompleted task count.`, + Args: cobra.ExactArgs(1), + RunE: runShow, +} + +func runShow(cmd *cobra.Command, args []string) error { + areaKey := args[0] + + cfg, err := config.Load() + if err != nil { + 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) + } + + apiClient, err := client.New() + if err != nil { + return err + } + + counter := stats.NewTaskCounter(apiClient) + + return printAreaDetails(cmd, area, counter) +} + +func printAreaDetails(cmd *cobra.Command, area *config.Area, counter *stats.TaskCounter) error { + link, _ := deeplink.Build(deeplink.Area, area.ID) + + fmt.Fprintf(cmd.OutOrStdout(), "%s (%s)\n", ui.H1.Render(area.Name), area.Key) + fmt.Fprintf(cmd.OutOrStdout(), " ID: %s\n", area.ID) + fmt.Fprintf(cmd.OutOrStdout(), " Link: %s\n", link) + + taskCount, err := counter.UncompletedInArea(cmd.Context(), area.ID) + if err != nil { + fmt.Fprintf(cmd.OutOrStdout(), " Tasks: %s\n", ui.Warning.Render("unable to fetch")) + } else { + fmt.Fprintf(cmd.OutOrStdout(), " Tasks: %d uncompleted\n", taskCount) + } + + if len(area.Goals) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "\n No goals configured") + + return nil + } + + fmt.Fprintln(cmd.OutOrStdout()) + + for _, goal := range area.Goals { + if err := printGoalSummary(cmd, &goal, counter); err != nil { + return err + } + } + + return nil +} + +func printGoalSummary(cmd *cobra.Command, goal *config.Goal, counter *stats.TaskCounter) error { + goalLink, _ := deeplink.Build(deeplink.Goal, goal.ID) + + fmt.Fprintf(cmd.OutOrStdout(), " %s (%s)\n", ui.H2.Render(goal.Name), goal.Key) + fmt.Fprintf(cmd.OutOrStdout(), " ID: %s\n", goal.ID) + fmt.Fprintf(cmd.OutOrStdout(), " Link: %s\n", goalLink) + + taskCount, err := counter.UncompletedInGoal(cmd.Context(), goal.ID) + if err != nil { + fmt.Fprintf(cmd.OutOrStdout(), " Tasks: %s\n", ui.Warning.Render("unable to fetch")) + } else { + fmt.Fprintf(cmd.OutOrStdout(), " Tasks: %d uncompleted\n", taskCount) + } + + return nil +} diff --git a/cmd/root.go b/cmd/root.go index db2de9312abb7d1ad0c4838d84d920a76d3b41f5..29069dda356ebb5e627cd103083946fd94a2df27 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "context" "os" + "git.secluded.site/lune/cmd/area" "git.secluded.site/lune/cmd/habit" initcmd "git.secluded.site/lune/cmd/init" "git.secluded.site/lune/cmd/journal" @@ -42,6 +43,7 @@ func init() { rootCmd.AddCommand(initcmd.Cmd) rootCmd.AddCommand(pingCmd) + rootCmd.AddCommand(area.Cmd) rootCmd.AddCommand(task.Cmd) rootCmd.AddCommand(note.Cmd) rootCmd.AddCommand(person.Cmd)