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
12 changes: 11 additions & 1 deletion cmd/history/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -19,6 +21,7 @@ var (
logToday bool
logWeek bool
logDate string
logJson bool
)

func LogCmd() *cobra.Command {
Expand All @@ -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()

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down
104 changes: 103 additions & 1 deletion cmd/history/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
102 changes: 102 additions & 0 deletions cmd/history/stats_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
26 changes: 25 additions & 1 deletion cmd/tracking/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,35 @@ 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",
Short: "Show current tracking status",
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 {
Expand All @@ -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()
Expand Down Expand Up @@ -61,5 +83,7 @@ func StatusCmd() *cobra.Command {
},
}

cmd.Flags().BoolVar(&statusJson, "json", false, "Output status as JSON")

return cmd
}
Loading