From bea73ca7a0c5134b6b5296ceb22205c54a7a249d Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Mon, 20 Jul 2026 15:56:11 -0600 Subject: [PATCH 1/2] Add JSON output for status and log commands --- cmd/history/log.go | 12 +++++- cmd/tracking/status.go | 26 ++++++++++++- docs/usage.md | 22 +++++++++++ internal/export/export_test.go | 70 ++++++++++++++++++++++++++++++++++ internal/export/json.go | 31 ++++++++++----- 5 files changed, 149 insertions(+), 12 deletions(-) diff --git a/cmd/history/log.go b/cmd/history/log.go index a2489fd..3d28403 100644 --- a/cmd/history/log.go +++ b/cmd/history/log.go @@ -2,9 +2,11 @@ package history import ( "fmt" + "os" "slices" "time" + "github.com/DylanDevelops/tmpo/internal/export" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/settings" "github.com/DylanDevelops/tmpo/internal/storage" @@ -19,6 +21,7 @@ var ( logToday bool logWeek bool logDate string + logJson bool ) func LogCmd() *cobra.Command { @@ -27,7 +30,9 @@ func LogCmd() *cobra.Command { Short: "View time tracking history", Long: `Display past time tracking entries with optional filtering.`, RunE: func(cmd *cobra.Command, args []string) error { - ui.NewlineAbove() + if !logJson { + ui.NewlineAbove() + } db, err := storage.Initialize() @@ -90,6 +95,10 @@ func LogCmd() *cobra.Command { return err } + if logJson { + return export.EncodeJson(os.Stdout, export.BuildExportEntries(entries, false)) + } + if len(entries) == 0 { ui.PrintWarning(ui.EmojiWarning, "No time entries found.") ui.NewlineBelow() @@ -152,6 +161,7 @@ func LogCmd() *cobra.Command { cmd.Flags().BoolVarP(&logToday, "today", "t", false, "Show today's entries") cmd.Flags().BoolVarP(&logWeek, "week", "w", false, "Show this week's entries") cmd.Flags().StringVarP(&logDate, "date", "d", "", "Show entries for a specific date") + cmd.Flags().BoolVar(&logJson, "json", false, "Output entries as JSON") return cmd } diff --git a/cmd/tracking/status.go b/cmd/tracking/status.go index dd2aa95..c5b11b1 100644 --- a/cmd/tracking/status.go +++ b/cmd/tracking/status.go @@ -2,14 +2,25 @@ package tracking import ( "fmt" + "os" "time" + "github.com/DylanDevelops/tmpo/internal/export" "github.com/DylanDevelops/tmpo/internal/settings" "github.com/DylanDevelops/tmpo/internal/storage" "github.com/DylanDevelops/tmpo/internal/ui" "github.com/spf13/cobra" ) +var ( + statusJson bool +) + +type statusOutput struct { + Tracking bool `json:"tracking"` + Entry *export.ExportEntry `json:"entry,omitempty"` +} + func StatusCmd() *cobra.Command { cmd := &cobra.Command{ Use: "status", @@ -17,7 +28,9 @@ func StatusCmd() *cobra.Command { Long: `Display information about the currently running time tracking session.`, RunE: func(cmd *cobra.Command, args []string) error { - ui.NewlineAbove() + if !statusJson { + ui.NewlineAbove() + } db, err := storage.Initialize() if err != nil { @@ -33,6 +46,15 @@ func StatusCmd() *cobra.Command { return err } + if statusJson { + output := statusOutput{Tracking: running != nil} + if running != nil { + entry := export.BuildExportEntries([]*storage.TimeEntry{running}, false)[0] + output.Entry = &entry + } + return export.EncodeJson(os.Stdout, output) + } + if running == nil { ui.PrintWarning(ui.EmojiWarning, "Not currently tracking time") ui.NewlineBelow() @@ -61,5 +83,7 @@ func StatusCmd() *cobra.Command { }, } + cmd.Flags().BoolVar(&statusJson, "json", false, "Output status as JSON") + return cmd } diff --git a/docs/usage.md b/docs/usage.md index 395d38f..4341d49 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -92,6 +92,10 @@ tmpo resume --project "Client Work" # Resume a global project from anywhere View the current tracking session with elapsed time. +**Options:** + +- `--json` - Output machine-readable JSON instead of styled text (for scripts and status bars) + ```bash tmpo status # Output: @@ -99,6 +103,22 @@ tmpo status # Started: 2:30 PM # Duration: 1h 23m # Description: Implementing feature + +tmpo status --json +# Output when tracking: +# { +# "tracking": true, +# "entry": { +# "project": "my-project", +# "start_time": "2026-01-15T14:30:00-05:00", +# "duration_hours": 1.38, +# "description": "Implementing feature" +# } +# } +# Output when idle: +# { +# "tracking": false +# } ``` ### `tmpo log` @@ -113,6 +133,7 @@ View your time tracking history. - `--today` - Show only today's entries - `--week` - Show this week's entries - `--date` - Show a specific date's entries +- `--json` - Output entries as a machine-readable JSON array (for scripts and integrations) **Examples:** @@ -124,6 +145,7 @@ tmpo log --milestone "Sprint 1" # Filter by milestone tmpo log --today # Show today's entries tmpo log --week # Show this week's entries tmpo log --date "2026-01-15" # Show January 15th, 2026 entries +tmpo log --today --json # Today's entries as JSON ``` ### `tmpo stats` diff --git a/internal/export/export_test.go b/internal/export/export_test.go index 2b0defb..ac7d07b 100644 --- a/internal/export/export_test.go +++ b/internal/export/export_test.go @@ -1,10 +1,12 @@ package export import ( + "bytes" "encoding/csv" "encoding/json" "os" "path/filepath" + "strings" "testing" "time" @@ -302,3 +304,71 @@ func TestToJson(t *testing.T) { } }) } + +func TestBuildExportEntries(t *testing.T) { + startTime := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) + endTime := time.Date(2024, 1, 1, 17, 0, 0, 0, time.UTC) + milestone := "v1" + + t.Run("orders entries oldest-first and maps fields", func(t *testing.T) { + entries := []*storage.TimeEntry{ + {ProjectName: "newest", StartTime: startTime, EndTime: &endTime, Description: "second"}, + {ProjectName: "oldest", StartTime: startTime, EndTime: &endTime, Description: "first", MilestoneName: &milestone}, + } + + result := BuildExportEntries(entries, true) + + assert.Len(t, result, 2) + // slices.Backward reverses input order, so the last input comes first + assert.Equal(t, "oldest", result[0].Project) + assert.Equal(t, "2024-01-01T09:00:00Z", result[0].StartTime) + assert.Equal(t, "2024-01-01T17:00:00Z", result[0].EndTime) + assert.Equal(t, 8.0, result[0].Duration) + assert.Equal(t, "v1", result[0].Milestone) + assert.Equal(t, "newest", result[1].Project) + assert.Empty(t, result[1].Milestone) + }) + + t.Run("omits end time for running entries", func(t *testing.T) { + entries := []*storage.TimeEntry{ + {ProjectName: "running", StartTime: startTime, EndTime: nil}, + } + + result := BuildExportEntries(entries, true) + + assert.Len(t, result, 1) + assert.Empty(t, result[0].EndTime) + }) + + t.Run("returns non-nil empty slice for no entries", func(t *testing.T) { + result := BuildExportEntries([]*storage.TimeEntry{}, false) + + assert.NotNil(t, result) + assert.Len(t, result, 0) + + // A nil slice would marshal to "null"; ensure an empty array is emitted + encoded, err := json.Marshal(result) + assert.NoError(t, err) + assert.Equal(t, "[]", string(encoded)) + }) +} + +func TestEncodeJson(t *testing.T) { + t.Run("writes indented JSON to the writer", func(t *testing.T) { + var buf bytes.Buffer + err := EncodeJson(&buf, map[string]bool{"tracking": false}) + + assert.NoError(t, err) + assert.Contains(t, buf.String(), "\"tracking\": false") + // SetIndent adds two-space indentation + assert.True(t, strings.Contains(buf.String(), "\n \"tracking\"")) + }) + + t.Run("encodes an empty slice as an array", func(t *testing.T) { + var buf bytes.Buffer + err := EncodeJson(&buf, []ExportEntry{}) + + assert.NoError(t, err) + assert.Equal(t, "[]", strings.TrimSpace(buf.String())) + }) +} diff --git a/internal/export/json.go b/internal/export/json.go index 688cee0..9c54f6f 100644 --- a/internal/export/json.go +++ b/internal/export/json.go @@ -3,6 +3,7 @@ package export import ( "encoding/json" "fmt" + "io" "os" "slices" "time" @@ -20,8 +21,8 @@ type ExportEntry struct { Milestone string `json:"milestone,omitempty"` } -func ToJson(entries []*storage.TimeEntry, filename string, inUtc bool) error { - var exportEntries []ExportEntry +func BuildExportEntries(entries []*storage.TimeEntry, inUtc bool) []ExportEntry { + exportEntries := make([]ExportEntry, 0, len(entries)) for _, entry := range slices.Backward(entries) { export := ExportEntry{ @@ -42,23 +43,33 @@ func ToJson(entries []*storage.TimeEntry, filename string, inUtc bool) error { exportEntries = append(exportEntries, export) } - file, err := os.Create(filename) - if err != nil { - return fmt.Errorf("failed to create JSON file: %w", err) - } - - defer file.Close() + return exportEntries +} - encoder := json.NewEncoder(file) +func EncodeJson(w io.Writer, v any) error { + encoder := json.NewEncoder(w) encoder.SetIndent("", " ") - if err := encoder.Encode(exportEntries); err != nil { + if err := encoder.Encode(v); err != nil { return fmt.Errorf("failed to encode JSON: %w", err) } return nil } +func ToJson(entries []*storage.TimeEntry, filename string, inUtc bool) error { + exportEntries := BuildExportEntries(entries, inUtc) + + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create JSON file: %w", err) + } + + defer file.Close() + + return EncodeJson(file, exportEntries) +} + func toCorrectJsonTimestamp(timestamp time.Time, inUtc bool) string { formattedTimestamp := "" From 7569242782019b4ec86ddfa7d273d4f28ac3eaa6 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Mon, 20 Jul 2026 16:09:45 -0600 Subject: [PATCH 2/2] Add JSON output support to stats command --- cmd/history/stats.go | 104 +++++++++++++++++++++++++++++++++++++- cmd/history/stats_test.go | 102 +++++++++++++++++++++++++++++++++++++ docs/usage.md | 10 ++-- 3 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 cmd/history/stats_test.go diff --git a/cmd/history/stats.go b/cmd/history/stats.go index 709a02e..1335eb8 100644 --- a/cmd/history/stats.go +++ b/cmd/history/stats.go @@ -2,10 +2,13 @@ package history import ( "fmt" + "math" + "os" "sort" "time" "github.com/DylanDevelops/tmpo/internal/currency" + "github.com/DylanDevelops/tmpo/internal/export" "github.com/DylanDevelops/tmpo/internal/settings" "github.com/DylanDevelops/tmpo/internal/storage" "github.com/DylanDevelops/tmpo/internal/ui" @@ -17,15 +20,35 @@ var ( statsWeek bool statsMonth bool statsDate string + statsJson bool ) +type projectStat struct { + Project string `json:"project"` + Hours float64 `json:"hours"` + Percentage float64 `json:"percentage"` + Earnings *float64 `json:"earnings,omitempty"` +} + +type statsOutput struct { + Period string `json:"period"` + TotalHours float64 `json:"total_hours"` + TotalEntries int `json:"total_entries"` + ProjectsTracked *int `json:"projects_tracked,omitempty"` + TotalEarnings *float64 `json:"total_earnings,omitempty"` + Currency string `json:"currency,omitempty"` + ByProject []projectStat `json:"by_project"` +} + func StatsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "stats", Short: "Show time tracking statistics", Long: `Display statistics and summaries of your time tracking data.`, RunE: func(cmd *cobra.Command, args []string) error { - ui.NewlineAbove() + if !statsJson { + ui.NewlineAbove() + } db, err := storage.Initialize() if err != nil { @@ -75,6 +98,10 @@ func StatsCmd() *cobra.Command { return err } + if statsJson { + return export.EncodeJson(os.Stdout, buildAllTimeStatsOutput(entries, db)) + } + ShowAllTimeStats(entries, db) return nil } @@ -85,6 +112,10 @@ func StatsCmd() *cobra.Command { return err } + if statsJson { + return export.EncodeJson(os.Stdout, buildStatsOutput(entries, periodName, nil)) + } + ShowPeriodStats(entries, periodName) return nil @@ -95,6 +126,7 @@ func StatsCmd() *cobra.Command { cmd.Flags().BoolVarP(&statsWeek, "week", "w", false, "Show this week's stats") cmd.Flags().BoolVarP(&statsMonth, "month", "m", false, "Show this month's stats") cmd.Flags().StringVarP(&statsDate, "date", "d", "", "Show stats for a specific date") + cmd.Flags().BoolVar(&statsJson, "json", false, "Output stats as JSON") return cmd } @@ -230,3 +262,73 @@ func getCurrencyCode() string { } return globalCfg.Currency } + +func buildStatsOutput(entries []*storage.TimeEntry, period string, projectsTracked *int) statsOutput { + projectStats := make(map[string]time.Duration) + projectEarnings := make(map[string]float64) + var totalDuration time.Duration + var totalEarnings float64 + hasAnyEarnings := false + + for _, entry := range entries { + duration := entry.Duration() + projectStats[entry.ProjectName] += duration + totalDuration += duration + + if entry.HourlyRate != nil { + earnings := entry.RoundedHours() * *entry.HourlyRate + projectEarnings[entry.ProjectName] += earnings + totalEarnings += earnings + hasAnyEarnings = true + } + } + + var projects []string + for project := range projectStats { + projects = append(projects, project) + } + sort.Strings(projects) + + byProject := make([]projectStat, 0, len(projects)) + for _, project := range projects { + duration := projectStats[project] + percentage := 0.0 + if totalDuration > 0 { + percentage = (duration.Seconds() / totalDuration.Seconds()) * 100 + } + + stat := projectStat{ + Project: project, + Hours: duration.Hours(), + Percentage: math.Round(percentage*10) / 10, + } + + if earnings, ok := projectEarnings[project]; ok && earnings > 0 { + stat.Earnings = &earnings + } + + byProject = append(byProject, stat) + } + + output := statsOutput{ + Period: period, + TotalHours: totalDuration.Hours(), + TotalEntries: len(entries), + ProjectsTracked: projectsTracked, + ByProject: byProject, + } + + if hasAnyEarnings { + output.TotalEarnings = &totalEarnings + output.Currency = getCurrencyCode() + } + + return output +} + +func buildAllTimeStatsOutput(entries []*storage.TimeEntry, db *storage.Database) statsOutput { + allProjects, _ := db.GetAllProjects() + count := len(allProjects) + + return buildStatsOutput(entries, "All Time", &count) +} diff --git a/cmd/history/stats_test.go b/cmd/history/stats_test.go new file mode 100644 index 0000000..9edbe04 --- /dev/null +++ b/cmd/history/stats_test.go @@ -0,0 +1,102 @@ +package history + +import ( + "testing" + "time" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/stretchr/testify/assert" +) + +func entryWithRate(project string, hours float64, rate *float64) *storage.TimeEntry { + start := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) + end := start.Add(time.Duration(hours * float64(time.Hour))) + + return &storage.TimeEntry{ + ProjectName: project, + StartTime: start, + EndTime: &end, + HourlyRate: rate, + } +} + +func TestBuildStatsOutput(t *testing.T) { + t.Run("aggregates totals and sorts projects alphabetically", func(t *testing.T) { + entries := []*storage.TimeEntry{ + entryWithRate("zeta", 1, nil), + entryWithRate("alpha", 3, nil), + entryWithRate("alpha", 0, nil), + } + + out := buildStatsOutput(entries, "Today", nil) + + assert.Equal(t, "Today", out.Period) + assert.Equal(t, 4.0, out.TotalHours) + assert.Equal(t, 3, out.TotalEntries) + assert.Nil(t, out.ProjectsTracked) + assert.Nil(t, out.TotalEarnings) + assert.Empty(t, out.Currency) + + // Sorted: alpha (3h) before zeta (1h) + assert.Len(t, out.ByProject, 2) + assert.Equal(t, "alpha", out.ByProject[0].Project) + assert.Equal(t, 3.0, out.ByProject[0].Hours) + assert.Equal(t, 75.0, out.ByProject[0].Percentage) + assert.Equal(t, "zeta", out.ByProject[1].Project) + assert.Equal(t, 25.0, out.ByProject[1].Percentage) + }) + + t.Run("rounds percentage to one decimal", func(t *testing.T) { + entries := []*storage.TimeEntry{ + entryWithRate("a", 1, nil), + entryWithRate("b", 1, nil), + entryWithRate("c", 1, nil), + } + + out := buildStatsOutput(entries, "Today", nil) + + // 1/3 => 33.333... rounded to 33.3 + for _, p := range out.ByProject { + assert.Equal(t, 33.3, p.Percentage) + } + }) + + t.Run("includes earnings and currency when rates are present", func(t *testing.T) { + rate := 100.0 + entries := []*storage.TimeEntry{ + entryWithRate("billed", 2, &rate), + entryWithRate("unbilled", 1, nil), + } + + out := buildStatsOutput(entries, "Today", nil) + + assert.NotNil(t, out.TotalEarnings) + assert.Equal(t, 200.0, *out.TotalEarnings) + assert.NotEmpty(t, out.Currency) + + // Only the billed project carries earnings + assert.Equal(t, "billed", out.ByProject[0].Project) + assert.NotNil(t, out.ByProject[0].Earnings) + assert.Equal(t, 200.0, *out.ByProject[0].Earnings) + assert.Equal(t, "unbilled", out.ByProject[1].Project) + assert.Nil(t, out.ByProject[1].Earnings) + }) + + t.Run("passes through projects tracked for all-time view", func(t *testing.T) { + count := 5 + out := buildStatsOutput(nil, "All Time", &count) + + assert.NotNil(t, out.ProjectsTracked) + assert.Equal(t, 5, *out.ProjectsTracked) + }) + + t.Run("empty entries produce a zeroed object with a non-nil breakdown", func(t *testing.T) { + out := buildStatsOutput([]*storage.TimeEntry{}, "Today", nil) + + assert.Equal(t, 0.0, out.TotalHours) + assert.Equal(t, 0, out.TotalEntries) + assert.Nil(t, out.TotalEarnings) + assert.NotNil(t, out.ByProject) + assert.Len(t, out.ByProject, 0) + }) +} diff --git a/docs/usage.md b/docs/usage.md index 4341d49..1a9eb24 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -158,15 +158,17 @@ Display statistics about your tracked time. - `--week` - Show this week's statistics - `--month` - Show this month's statistics - `--date` - Show a specific date's statistics +- `--json` - Output statistics as machine-readable JSON (for scripts and dashboards) **Examples:** ```bash -tmpo stats # All-time stats -tmpo stats --today # Today's stats -tmpo stats --week # This week's stats -tmpo stats --month # This month's stats +tmpo stats # All-time stats +tmpo stats --today # Today's stats +tmpo stats --week # This week's stats +tmpo stats --month # This month's stats tmpo stats --date "2026-01-15" # January 15th, 2026 entries +tmpo stats --week --json # This week's stats as JSON ``` ## Configuration