diff --git a/README.md b/README.md index eaec257..508b273 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ dispatch stats dispatch stats --json dispatch stats --calendar dispatch stats --repo jongio/dispatch --since 2026-01-01 +dispatch stats --top 5 ``` Flags: @@ -229,6 +230,7 @@ Flags: - `--json` prints the summary as a single JSON object. - `--calendar` adds a GitHub-style activity heatmap of sessions per day, with an intensity legend. It honors the `--repo`, `--branch`, `--since`, and `--until` filters. - `--repo`, `--branch`, `--folder`, `--since`, and `--until` narrow which sessions are counted. +- `--top ` caps each repository, branch, and host breakdown to the first N entries. ### Tags diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index e6833e7..87d1006 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -137,6 +137,7 @@ Stats flags: --folder Only count sessions under a folder --since Only count sessions created on or after a date --until Only count sessions created on or before a date + --top Limit each breakdown to the top N entries Search flags: --json Print results as JSON (default) diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index 08e19a2..c76a10e 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -457,13 +457,18 @@ func TestPrintUsage_Output(t *testing.T) { origStdout := os.Stdout os.Stdout = w + var buf bytes.Buffer + readDone := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(readDone) + }() + printUsage() w.Close() os.Stdout = origStdout - - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) + <-readDone output := buf.String() for _, want := range []string{"dispatch", "help", "version", "update", "--demo"} { diff --git a/cmd/dispatch/stats.go b/cmd/dispatch/stats.go index 7c0bad4..73a6d00 100644 --- a/cmd/dispatch/stats.go +++ b/cmd/dispatch/stats.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "sort" + "strconv" "strings" "time" @@ -26,6 +27,7 @@ type statsOptions struct { filter data.FilterOptions json bool calendar bool + top int } // countEntry is one label and count pair in a grouped breakdown. @@ -83,6 +85,7 @@ func runStats(w io.Writer, args []string) error { cal := buildActivityCalendar(sessions) report.Calendar = &cal } + applyStatsTopLimit(&report, opts.top) if opts.json { return writeStatsJSON(w, report) } @@ -122,6 +125,17 @@ func parseStatsArgs(args []string) (statsOptions, error) { opts.json = true case name == "--calendar": opts.calendar = true + case name == "--top": + v, ni, err := takeValue(i, "--top", inlineOrEmpty(inline, hasInline)) + if err != nil { + return statsOptions{}, err + } + top, err := parseStatsTop(v) + if err != nil { + return statsOptions{}, err + } + opts.top = top + i = ni case name == "--repo" || name == "--repository": v, ni, err := takeValue(i, "--repo", inlineOrEmpty(inline, hasInline)) if err != nil { @@ -175,6 +189,14 @@ func parseStatsArgs(args []string) (statsOptions, error) { return opts, nil } +func parseStatsTop(v string) (int, error) { + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil || n <= 0 { + return 0, fmt.Errorf("invalid --top value %q (want a positive whole number)", v) + } + return n, nil +} + // splitFlag separates a flag token into its name and optional inline value, // e.g. "--repo=foo" becomes ("--repo", "foo", true). func splitFlag(arg string) (name, value string, hasValue bool) { @@ -257,6 +279,22 @@ func buildStatsReport(sessions []data.Session) statsReport { return report } +func applyStatsTopLimit(report *statsReport, top int) { + if report == nil || top <= 0 { + return + } + report.ByRepository = capCountEntries(report.ByRepository, top) + report.ByBranch = capCountEntries(report.ByBranch, top) + report.ByHostType = capCountEntries(report.ByHostType, top) +} + +func capCountEntries(entries []countEntry, top int) []countEntry { + if top <= 0 || len(entries) <= top { + return entries + } + return entries[:top] +} + // latestTime returns the most recent timestamp for a session, preferring // LastActiveAt and falling back to UpdatedAt then CreatedAt. func latestTime(s data.Session) (time.Time, bool) { diff --git a/cmd/dispatch/stats_test.go b/cmd/dispatch/stats_test.go index e388f4a..cb5613a 100644 --- a/cmd/dispatch/stats_test.go +++ b/cmd/dispatch/stats_test.go @@ -111,7 +111,7 @@ func TestBuildStatsReportEmpty(t *testing.T) { } func TestParseStatsArgs(t *testing.T) { - opts, err := parseStatsArgs([]string{"stats", "--json", "--repo", "jongio/dispatch", "--branch=main", "--since", "2026-01-01", "--until=2026-07-01"}) + opts, err := parseStatsArgs([]string{"stats", "--json", "--repo", "jongio/dispatch", "--branch=main", "--since", "2026-01-01", "--until=2026-07-01", "--top", "2"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -130,6 +130,9 @@ func TestParseStatsArgs(t *testing.T) { if opts.filter.Since.Format("2006-01-02") != "2026-01-01" { t.Errorf("Since = %v", opts.filter.Since) } + if opts.top != 2 { + t.Errorf("top = %d, want 2", opts.top) + } } func TestParseStatsArgsErrors(t *testing.T) { @@ -138,6 +141,10 @@ func TestParseStatsArgsErrors(t *testing.T) { {"stats", "--since", "not-date"}, // bad date {"stats", "--bogus"}, // unknown flag {"stats", "extra"}, // positional + {"stats", "--top"}, // missing value + {"stats", "--top", "0"}, // not positive + {"stats", "--top", "-1"}, // negative + {"stats", "--top", "many"}, // not a number } for _, args := range cases { if _, err := parseStatsArgs(args); err == nil { @@ -146,6 +153,21 @@ func TestParseStatsArgsErrors(t *testing.T) { } } +func TestApplyStatsTopLimit(t *testing.T) { + report := buildStatsReport(sampleSessions()) + applyStatsTopLimit(&report, 1) + + if len(report.ByRepository) != 1 || report.ByRepository[0].Label != "jongio/dispatch" { + t.Fatalf("ByRepository = %+v, want only jongio/dispatch", report.ByRepository) + } + if len(report.ByBranch) != 1 { + t.Fatalf("ByBranch len = %d, want 1", len(report.ByBranch)) + } + if report.TotalSessions != 3 { + t.Errorf("TotalSessions = %d, want unchanged total 3", report.TotalSessions) + } +} + func TestRunStatsText(t *testing.T) { withStatsList(t, func(data.FilterOptions) ([]data.Session, error) { return sampleSessions(), nil @@ -163,6 +185,24 @@ func TestRunStatsText(t *testing.T) { } } +func TestRunStatsTextTop(t *testing.T) { + withStatsList(t, func(data.FilterOptions) ([]data.Session, error) { + return sampleSessions(), nil + }) + + var buf bytes.Buffer + if err := runStats(&buf, []string{"stats", "--top", "1"}); err != nil { + t.Fatalf("runStats: %v", err) + } + out := buf.String() + if !strings.Contains(out, "Sessions: 3") { + t.Errorf("top should not change totals\n%s", out) + } + if strings.Contains(out, "feature") { + t.Errorf("top 1 should hide lower branch rows\n%s", out) + } +} + func TestRunStatsJSON(t *testing.T) { withStatsList(t, func(data.FilterOptions) ([]data.Session, error) { return sampleSessions(), nil @@ -181,6 +221,27 @@ func TestRunStatsJSON(t *testing.T) { } } +func TestRunStatsJSONTop(t *testing.T) { + withStatsList(t, func(data.FilterOptions) ([]data.Session, error) { + return sampleSessions(), nil + }) + + var buf bytes.Buffer + if err := runStats(&buf, []string{"stats", "--json", "--top=1"}); err != nil { + t.Fatalf("runStats: %v", err) + } + var report statsReport + if err := json.Unmarshal(buf.Bytes(), &report); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String()) + } + if len(report.ByRepository) != 1 || len(report.ByBranch) != 1 || len(report.ByHostType) != 1 { + t.Errorf("top did not cap breakdown arrays: %+v", report) + } + if report.TotalSessions != 3 { + t.Errorf("TotalSessions = %d, want 3", report.TotalSessions) + } +} + func TestRunStatsPassesFilter(t *testing.T) { var got data.FilterOptions withStatsList(t, func(f data.FilterOptions) ([]data.Session, error) {