diff --git a/README.md b/README.md index 7752edc..92758b7 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,23 @@ Run `dispatch doctor` to print setup checks for the config file, session store, Add `--json` (`dispatch doctor --json`) to print the same checks as a single JSON object for scripts and CI. +### Statistics + +Run `dispatch stats` to print session totals and breakdowns by repository, branch, and host type. + +```sh +dispatch stats +dispatch stats --json +dispatch stats --calendar +dispatch stats --repo jongio/dispatch --since 2026-01-01 +``` + +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. + ### Key Bindings #### Navigation diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index d49f766..1f5d3c1 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -99,6 +99,7 @@ Commands: Stats flags: --json Print the summary as JSON + --calendar Add a per-day activity heatmap --repo Only count sessions for a repository --branch Only count sessions on a branch --folder Only count sessions under a folder diff --git a/cmd/dispatch/stats.go b/cmd/dispatch/stats.go index 08b70b5..ee4aa73 100644 --- a/cmd/dispatch/stats.go +++ b/cmd/dispatch/stats.go @@ -23,8 +23,9 @@ const statsQueryLimit = 100_000 // statsOptions holds the parsed flags for the stats command. type statsOptions struct { - filter data.FilterOptions - json bool + filter data.FilterOptions + json bool + calendar bool } // countEntry is one label and count pair in a grouped breakdown. @@ -40,9 +41,24 @@ type statsReport struct { TotalFiles int `json:"total_files"` Earliest string `json:"earliest,omitempty"` Latest string `json:"latest,omitempty"` - ByRepository []countEntry `json:"by_repository"` - ByBranch []countEntry `json:"by_branch"` - ByHostType []countEntry `json:"by_host_type"` + ByRepository []countEntry `json:"by_repository"` + ByBranch []countEntry `json:"by_branch"` + ByHostType []countEntry `json:"by_host_type"` + Calendar *activityCalendar `json:"calendar,omitempty"` +} + +// dayCount is the session count for a single calendar day. +type dayCount struct { + Date string `json:"date"` // YYYY-MM-DD (UTC) + Count int `json:"count"` + Intensity int `json:"intensity"` // 0-4 heatmap bucket +} + +// activityCalendar is a continuous per-day session-count series used to render +// a contribution-style heatmap. +type activityCalendar struct { + Days []dayCount `json:"days"` + MaxCount int `json:"max_count"` } // runStats prints aggregate counts for the stored sessions. args is the full @@ -63,10 +79,17 @@ func runStats(w io.Writer, args []string) error { } report := buildStatsReport(sessions) + if opts.calendar { + cal := buildActivityCalendar(sessions) + report.Calendar = &cal + } if opts.json { return writeStatsJSON(w, report) } writeStatsText(w, report) + if opts.calendar { + writeActivityCalendar(w, *report.Calendar) + } return nil } @@ -97,6 +120,8 @@ func parseStatsArgs(args []string) (statsOptions, error) { switch { case name == "--json": opts.json = true + case name == "--calendar": + opts.calendar = true case name == "--repo" || name == "--repository": v, ni, err := takeValue(i, "--repo", inlineOrEmpty(inline, hasInline)) if err != nil { @@ -330,3 +355,129 @@ func defaultStatsListSessions(filter data.FilterOptions) ([]data.Session, error) } return sessions, nil } + +// buildActivityCalendar groups session creation dates into per-day counts with +// an intensity bucket (0-4) scaled to the busiest day. Every day between the +// first and last active day is included so gaps render as empty cells. Days +// are ordered chronologically. +func buildActivityCalendar(sessions []data.Session) activityCalendar { + counts := map[string]int{} + var first, last time.Time + + for _, s := range sessions { + t, ok := parseStatsTime(s.CreatedAt) + if !ok { + continue + } + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + counts[day.Format("2006-01-02")]++ + if first.IsZero() || day.Before(first) { + first = day + } + if last.IsZero() || day.After(last) { + last = day + } + } + + cal := activityCalendar{Days: []dayCount{}} + if first.IsZero() { + return cal + } + + maxCount := 0 + for _, c := range counts { + if c > maxCount { + maxCount = c + } + } + cal.MaxCount = maxCount + + for day := first; !day.After(last); day = day.AddDate(0, 0, 1) { + key := day.Format("2006-01-02") + count := counts[key] + cal.Days = append(cal.Days, dayCount{ + Date: key, + Count: count, + Intensity: intensityBucket(count, maxCount), + }) + } + return cal +} + +// intensityBucket maps a day's count to a 0-4 heatmap level relative to the +// busiest day. Zero counts are level 0; the rest split into quartiles. +func intensityBucket(count, maxCount int) int { + if count <= 0 || maxCount <= 0 { + return 0 + } + ratio := float64(count) / float64(maxCount) + switch { + case ratio <= 0.25: + return 1 + case ratio <= 0.5: + return 2 + case ratio <= 0.75: + return 3 + default: + return 4 + } +} + +// intensityRune returns the block character used to render a heatmap level. +func intensityRune(level int) rune { + switch level { + case 1: + return '░' + case 2: + return '▒' + case 3: + return '▓' + case 4: + return '█' + default: + return '·' + } +} + +// writeActivityCalendar prints a GitHub-style contribution grid: seven weekday +// rows across week columns, with a shaded block per day and an intensity +// legend below. +func writeActivityCalendar(w io.Writer, cal activityCalendar) { + fmt.Fprintln(w) + fmt.Fprintln(w, "Activity") + if len(cal.Days) == 0 { + fmt.Fprintln(w, " (no data)") + return + } + + byDate := make(map[string]dayCount, len(cal.Days)) + for _, d := range cal.Days { + byDate[d.Date] = d + } + + first, _ := time.Parse("2006-01-02", cal.Days[0].Date) + last, _ := time.Parse("2006-01-02", cal.Days[len(cal.Days)-1].Date) + + // Align the grid to whole weeks (Sunday start) so weekday rows line up. + gridStart := first.AddDate(0, 0, -int(first.Weekday())) + gridEnd := last.AddDate(0, 0, 6-int(last.Weekday())) + weeks := int(gridEnd.Sub(gridStart).Hours()/24)/7 + 1 + + labels := [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} + for wd := 0; wd < 7; wd++ { + var b strings.Builder + fmt.Fprintf(&b, " %s ", labels[wd]) + for wk := 0; wk < weeks; wk++ { + day := gridStart.AddDate(0, 0, wk*7+wd) + if day.Before(first) || day.After(last) { + b.WriteRune(' ') + continue + } + b.WriteRune(intensityRune(byDate[day.Format("2006-01-02")].Intensity)) + } + fmt.Fprintln(w, b.String()) + } + + fmt.Fprintf(w, " Less %c%c%c%c%c More\n", + intensityRune(0), intensityRune(1), intensityRune(2), intensityRune(3), intensityRune(4)) +} diff --git a/cmd/dispatch/stats_calendar_test.go b/cmd/dispatch/stats_calendar_test.go new file mode 100644 index 0000000..205f79d --- /dev/null +++ b/cmd/dispatch/stats_calendar_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/data" +) + +func TestIntensityBucket(t *testing.T) { + tests := []struct { + count, max, want int + }{ + {0, 10, 0}, + {5, 0, 0}, // no max -> level 0 + {1, 8, 1}, // ratio 0.125 -> 1 + {2, 8, 1}, // ratio 0.25 -> 1 + {3, 8, 2}, // ratio 0.375 -> 2 + {4, 8, 2}, // ratio 0.5 -> 2 + {5, 8, 3}, // ratio 0.625 -> 3 + {6, 8, 3}, // ratio 0.75 -> 3 + {7, 8, 4}, // ratio 0.875 -> 4 + {8, 8, 4}, // ratio 1.0 -> 4 + } + for _, tc := range tests { + if got := intensityBucket(tc.count, tc.max); got != tc.want { + t.Errorf("intensityBucket(%d, %d) = %d, want %d", tc.count, tc.max, got, tc.want) + } + } +} + +func TestBuildActivityCalendar_CountsAndGaps(t *testing.T) { + sessions := []data.Session{ + {CreatedAt: "2026-01-01T09:00:00Z"}, + {CreatedAt: "2026-01-01T18:00:00Z"}, // same day -> count 2 + {CreatedAt: "2026-01-04T10:00:00Z"}, // 2-day gap + } + + cal := buildActivityCalendar(sessions) + + if cal.MaxCount != 2 { + t.Errorf("MaxCount = %d, want 2", cal.MaxCount) + } + // Jan 1 through Jan 4 inclusive = 4 continuous days. + if len(cal.Days) != 4 { + t.Fatalf("len(Days) = %d, want 4 (%+v)", len(cal.Days), cal.Days) + } + + byDate := map[string]dayCount{} + for _, d := range cal.Days { + byDate[d.Date] = d + } + if byDate["2026-01-01"].Count != 2 { + t.Errorf("2026-01-01 count = %d, want 2", byDate["2026-01-01"].Count) + } + if byDate["2026-01-01"].Intensity != 4 { + t.Errorf("2026-01-01 intensity = %d, want 4 (busiest day)", byDate["2026-01-01"].Intensity) + } + // Gap days are present with zero count and intensity. + if d, ok := byDate["2026-01-02"]; !ok || d.Count != 0 || d.Intensity != 0 { + t.Errorf("2026-01-02 = %+v, want zero-count gap day", d) + } + if byDate["2026-01-04"].Count != 1 { + t.Errorf("2026-01-04 count = %d, want 1", byDate["2026-01-04"].Count) + } +} + +func TestBuildActivityCalendar_Empty(t *testing.T) { + cal := buildActivityCalendar(nil) + if len(cal.Days) != 0 || cal.MaxCount != 0 { + t.Errorf("empty calendar = %+v, want no days and MaxCount 0", cal) + } + + // Sessions with unparseable dates are skipped and produce an empty grid. + cal = buildActivityCalendar([]data.Session{{CreatedAt: "not-a-date"}}) + if len(cal.Days) != 0 { + t.Errorf("unparseable dates should be skipped, got %+v", cal.Days) + } +} + +func TestRunStatsCalendarText(t *testing.T) { + withStatsList(t, func(data.FilterOptions) ([]data.Session, error) { + return sampleSessions(), nil + }) + + var buf bytes.Buffer + if err := runStats(&buf, []string{"stats", "--calendar"}); err != nil { + t.Fatalf("runStats: %v", err) + } + out := buf.String() + for _, want := range []string{"Activity", "Sun ", "Sat ", "Less", "More"} { + if !strings.Contains(out, want) { + t.Errorf("calendar output missing %q\n%s", want, out) + } + } + // The default breakdown must still be present. + if !strings.Contains(out, "By repository") { + t.Errorf("default stats output should be unchanged\n%s", out) + } +} + +func TestRunStatsCalendarJSON(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", "--calendar"}); 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 report.Calendar == nil || len(report.Calendar.Days) == 0 { + t.Fatalf("expected calendar in JSON, got %+v", report.Calendar) + } +} + +func TestRunStatsNoCalendarByDefault(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"}); 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", err) + } + if report.Calendar != nil { + t.Errorf("calendar should be omitted without --calendar, got %+v", report.Calendar) + } + if strings.Contains(buf.String(), "\"calendar\"") { + t.Errorf("default JSON should not contain a calendar key\n%s", buf.String()) + } +} + +func TestParseStatsArgsCalendar(t *testing.T) { + opts, err := parseStatsArgs([]string{"stats", "--calendar"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !opts.calendar { + t.Error("expected calendar=true") + } +}