Skip to content
Open
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Commands:

Stats flags:
--json Print the summary as JSON
--calendar Add a per-day activity heatmap
--repo <name> Only count sessions for a repository
--branch <name> Only count sessions on a branch
--folder <path> Only count sessions under a folder
Expand Down
161 changes: 156 additions & 5 deletions cmd/dispatch/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
}
151 changes: 151 additions & 0 deletions cmd/dispatch/stats_calendar_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading