Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,15 @@ dispatch stats
dispatch stats --json
dispatch stats --calendar
dispatch stats --repo jongio/dispatch --since 2026-01-01
dispatch stats --top 5
```

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 <n>` caps each repository, branch, and host breakdown to the first N entries.

### Tags

Expand Down
1 change: 1 addition & 0 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ Stats flags:
--folder <path> Only count sessions under a folder
--since <date> Only count sessions created on or after a date
--until <date> Only count sessions created on or before a date
--top <n> Limit each breakdown to the top N entries

Search flags:
--json Print results as JSON (default)
Expand Down
11 changes: 8 additions & 3 deletions cmd/dispatch/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} {
Expand Down
38 changes: 38 additions & 0 deletions cmd/dispatch/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"sort"
"strconv"
"strings"
"time"

Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
63 changes: 62 additions & 1 deletion cmd/dispatch/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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) {
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand Down