From 8062c2d054ee5ce5cfc806f2a32a39547f36087c Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 7 Dec 2025 17:32:59 -0800 Subject: [PATCH 1/6] Add time entry and project query methods to Database Implements GetEntries, GetEntriesByProject, GetEntriesByDateRange, and GetAllProjects methods for retrieving time entries and distinct project names from the database. These methods support filtering by project, date range, and limiting results, improving data access flexibility. --- internal/storage/db.go | 166 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/internal/storage/db.go b/internal/storage/db.go index 4204b33..a14a774 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -186,6 +186,172 @@ func (d *Database) GetEntry(id int64) (*TimeEntry, error) { return &entry, nil } +// GetEntries retrieves time entries from the Database. +// +// It returns time entries ordered by start_time in descending order. If limit > 0, +// at most `limit` entries are returned; if limit <= 0 all matching entries are returned. +// Each returned element is a pointer to a TimeEntry. The EndTime field of a TimeEntry +// will be nil when the corresponding end_time column in the database is NULL. +// +// The function performs a SQL query selecting id, project_name, start_time, end_time, +// and description. It returns a slice of entries and an error if the query or row +// scanning fails; any underlying error is wrapped. +func (d *Database) GetEntries(limit int) ([]*TimeEntry, error) { + query := ` + SELECT id, project_name, start_time, end_time, description + FROM time_entries + ORDER BY start_time DESC + ` + + if limit > 0 { + query += fmt.Sprintf(" LIMIT %d", limit) + } + + rows, err := d.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to query entries: %w", err) + } + + defer rows.Close() + + var entries []*TimeEntry + + for rows.Next() { + var entry TimeEntry + var endTime sql.NullTime + + err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + if err != nil { + return nil, fmt.Errorf("failed to scan entry: %w", err) + } + + if endTime.Valid { + entry.EndTime = &endTime.Time + } + + entries = append(entries, &entry) + } + + return entries, nil +} + +// GetEntriesByProject retrieves time entries for the specified projectName from the +// time_entries table. Results are ordered by start_time in descending order (newest first). +// +// For each row a TimeEntry is populated. If the end_time column is NULL the returned +// TimeEntry.EndTime will be nil; otherwise EndTime will point to the scanned time.Time. +// +// On success the function returns a slice of pointers to TimeEntry. If there are no +// matching rows the returned slice will have length 0 (it may be nil). On failure the +// function returns a non-nil error and a nil slice. Errors may originate from the +// query execution, row scanning, or row iteration. +func (d *Database) GetEntriesByProject(projectName string) ([]*TimeEntry, error) { + rows, err := d.db.Query(` + SELECT id, project_name, start_time, end_time, description + FROM time_entries + WHERE project_name = ? + ORDER BY start_time DESC + `, projectName) + + if err != nil { + return nil, fmt.Errorf("failed to query entries: %w", err) + } + + defer rows.Close() + + var entries []*TimeEntry + + for rows.Next() { + var entry TimeEntry + var endTime sql.NullTime + + err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + if err != nil { + return nil, fmt.Errorf("failed to scan entry: %w", err) + } + + if endTime.Valid { + entry.EndTime = &endTime.Time + } + + entries = append(entries, &entry) + } + + return entries, nil +} + +// GetEntriesByDateRange retrieves time entries whose start_time falls between start and end (inclusive). +// Results are returned in descending order by start_time. +// The provided start and end times are passed to the database driver as-is; callers should ensure they use the intended timezone/representation. +// For rows with a NULL end_time the returned TimeEntry.EndTime will be nil; otherwise EndTime points to the parsed time value. +// Returns a slice of pointers to TimeEntry (which may be empty) or an error if the database query or row scanning fails. +func (d *Database) GetEntriesByDateRange(start, end time.Time) ([]*TimeEntry, error) { + rows, err := d.db.Query(` + SELECT id, project_name, start_time, end_time, description + FROM time_entries + WHERE start_time BETWEEN ? AND ? + ORDER BY start_time DESC + `, start, end) + + if err != nil { + return nil, fmt.Errorf("failed to query entries: %w", err) + } + + defer rows.Close() + + var entries []*TimeEntry + + for rows.Next() { + var entry TimeEntry + var endTime sql.NullTime + + err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + if err != nil { + return nil, fmt.Errorf("failed to scan entry: %w", err) + } + + if endTime.Valid { + entry.EndTime = &endTime.Time + } + + entries = append(entries, &entry) + } + + return entries, nil +} + +// GetAllProjects retrieves all distinct project names from the time_entries table. +// The results are returned in ascending order by project_name. +// On success it returns a slice of project names (which will be empty if no projects exist) +// and a nil error. If the underlying database query or a row scan fails, it returns a +// non-nil error describing the failure. +func (d *Database) GetAllProjects() ([]string, error) { + rows, err := d.db.Query(` + SELECT DISTINCT project_name + FROM time_entries + ORDER BY project_name + `) + + if err != nil { + return nil, fmt.Errorf("failed to query projects: %w", err) + } + + defer rows.Close() + + var projects []string + + for rows.Next() { + var project string + if err := rows.Scan(&project); err != nil { + return nil, fmt.Errorf("failed to scan project: %w", err) + } + + projects = append(projects, project) + } + + return projects, nil +} + // Close closes the Database, releasing any underlying resources. // It delegates to the wrapped database's Close method and returns any error encountered. // After Close is called, the Database must not be used for further operations. From 5972f5240ef73f48a2801562ae416070c7ffd707 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 7 Dec 2025 18:01:20 -0800 Subject: [PATCH 2/6] Add log command for viewing time entries Introduces a new 'log' command to display time tracking history with filtering options for limit, project, today, and week. Integrates with storage to fetch and format entries, showing total tracked time and entry details. --- cmd/log.go | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 cmd/log.go diff --git a/cmd/log.go b/cmd/log.go new file mode 100644 index 0000000..9d93ef1 --- /dev/null +++ b/cmd/log.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/spf13/cobra" +) + +var ( + logLimit int + logProject string + logToday bool + logWeek bool +) + +var logCmd = &cobra.Command{ + Use: "log", + Short: "View time tracking history", + Long: `Display past time tracking entries with optional filtering.`, + Run: func(cmd *cobra.Command, args []string) { + db, err := storage.Initialize() + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + defer db.Close() + + var entries []*storage.TimeEntry + + if logToday { + start := time.Now().Truncate(24 * time.Hour) + end := start.Add(24 * time.Hour) + entries, err = db.GetEntriesByDateRange(start, end) + } else if logWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // sunday + } + + start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end := start.AddDate(0, 0, 7) + entries, err = db.GetEntriesByDateRange(start, end) + } else if logProject != "" { + entries, err = db.GetEntriesByProject(logProject) + } else { + entries, err = db.GetEntries(logLimit) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if len(entries) == 0 { + fmt.Println("No time entries found.") + + return + } + + fmt.Printf("\n[tmpo] Time Entries (%d total)\n\n", len(entries)) + + var totalDuration time.Duration + currentDate := "" + + for _, entry := range entries { + entryDate := entry.StartTime.Format("Mon, Jan 2, 2006") + if entryDate != currentDate { + if currentDate != "" { + fmt.Println() + } + + fmt.Printf("─── %s ───\n", entryDate) + currentDate = entryDate + } + + duration := entry.Duration() + totalDuration += duration + + timeRange := entry.StartTime.Format("3:04 PM") + if entry.EndTime != nil { + timeRange += " - " + entry.EndTime.Format("3:04 PM") + } else { + timeRange += " - (running)" + } + + fmt.Printf(" %s %-20s %s\n", timeRange, entry.ProjectName, formatDuration(duration)) + if entry.Description != "" { + fmt.Printf(" └─ %s\n", entry.Description) + } + } + + fmt.Printf("\n─────────────────────────────────────────\n") + fmt.Printf("Total Time: %s\n", formatDuration(totalDuration)) + }, +} + +func init() { + rootCmd.AddCommand(logCmd) + + logCmd.Flags().IntVarP(&logLimit, "limit", "l", 10, "Number of entries to show") + logCmd.Flags().StringVarP(&logProject, "project", "p", "", "Filter by project name") + logCmd.Flags().BoolVarP(&logToday, "today", "t", false, "Show today's entries") + logCmd.Flags().BoolVarP(&logWeek, "week", "w", false, "Show this week's entries") +} From 7ec6407c49fdd7df1c53cd6d126f7ab479a3f2d3 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 7 Dec 2025 19:03:38 -0800 Subject: [PATCH 3/6] Add documentation for TimeEntry struct and methods Added detailed comments to the TimeEntry struct and its methods Duration and IsRunning to clarify their purpose and usage. --- internal/storage/models.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/storage/models.go b/internal/storage/models.go index 6c70392..fa1c72e 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -2,6 +2,10 @@ package storage import "time" +// TimeEntry represents a recorded period of work on a project. +// It includes a unique identifier, the project name, the start time, +// an optional end time (nil indicates the entry is still in progress), +// and a free-form description of the work performed. type TimeEntry struct { ID int64 ProjectName string @@ -10,6 +14,9 @@ type TimeEntry struct { Description string } +// Duration returns the elapsed time for the TimeEntry. +// If EndTime is non-nil, it returns the difference EndTime.Sub(StartTime). +// If EndTime is nil (the entry is ongoing), it returns time.Since(StartTime). func (t *TimeEntry) Duration() time.Duration { if( t.EndTime == nil) { return time.Since(t.StartTime) @@ -18,6 +25,8 @@ func (t *TimeEntry) Duration() time.Duration { return t.EndTime.Sub(t.StartTime) } +// IsRunning reports whether the TimeEntry is currently running. +// It returns true when EndTime is nil, indicating no end timestamp has been set. func (t *TimeEntry) IsRunning() bool { return t.EndTime == nil } \ No newline at end of file From 0e6ec715c8b42753210efa4586b0ad093954f7b1 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 7 Dec 2025 19:31:39 -0800 Subject: [PATCH 4/6] Add export command with CSV and JSON support Introduces an 'export' CLI command to export time entries in CSV or JSON format. Implements internal/export/csv.go and internal/export/json.go for file generation, supporting filtering by date and project. --- cmd/export.go | 117 ++++++++++++++++++++++++++++++++++++++++ internal/export/csv.go | 67 +++++++++++++++++++++++ internal/export/json.go | 73 +++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 cmd/export.go create mode 100644 internal/export/csv.go create mode 100644 internal/export/json.go diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 0000000..9d270c7 --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/DylanDevelops/tmpo/internal/export" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/spf13/cobra" +) + +var ( + exportFormat string + exportOutput string + exportProject string + exportToday bool + exportWeek bool +) + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Export time entries", + Long: `Export time tracking data to different formats.`, + Run: func(cmd *cobra.Command, args []string) { + db, err := storage.Initialize() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + defer db.Close() + + var entries []*storage.TimeEntry + + if exportToday { + start := time.Now().Truncate(24 * time.Hour) + end := start.Add(24 * time.Hour) + entries, err = db.GetEntriesByDateRange(start, end) + } else if exportWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // sunday + } + + start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end := start.AddDate(0, 0, 7) + entries, err = db.GetEntriesByDateRange(start, end) + } else if exportProject != "" { + entries, err = db.GetEntriesByProject(exportProject) + } else { + entries, err = db.GetEntries(0) // all + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + if len(entries) == 0 { + fmt.Println("No entries to export.") + + os.Exit(0) + } + + filename := exportOutput + if filename == "" { + timestamp := time.Now().Format("2006-01-02") + ext := "csv" + + if exportFormat == "json" { + ext = "json" + } + + filename = fmt.Sprintf("tmpo-export-%s.%s", timestamp, ext) + } + + if exportFormat == "csv" && filepath.Ext(filename) != ".csv" { + filename += ".csv" + } else if exportFormat == "json" && filepath.Ext(filename) != ".json" { + filename += ".json" + } + + switch exportFormat { + case "csv": + err = export.ToCSV(entries, filename) + case "json": + err = export.ToJson(entries, filename) + default: + fmt.Fprintf(os.Stderr, "Error: Unknown format '%s'. Use 'csv' or 'json'\n", exportFormat) + + os.Exit(1) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + fmt.Printf("[tmpo] Exported %d entries to %s\n", len(entries), filename) + }, +} + +func init() { + rootCmd.AddCommand(exportCmd) + + exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "csv", "Export format (csv or json)") + exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "Output filename") + exportCmd.Flags().StringVarP(&exportProject, "project", "p", "", "Filter by project") + exportCmd.Flags().BoolVarP(&exportToday, "today", "t", false, "Export today's entries") + exportCmd.Flags().BoolVarP(&exportWeek, "week", "w", false, "Export this week's entries") +} diff --git a/internal/export/csv.go b/internal/export/csv.go new file mode 100644 index 0000000..df5c162 --- /dev/null +++ b/internal/export/csv.go @@ -0,0 +1,67 @@ +package export + +import ( + "encoding/csv" + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/storage" +) + +// ToCSV writes the provided slice of time entries to a CSV file at the given +// filename. The file is created (or truncated if it already exists) and a CSV +// writer is used to emit a header row followed by one record per entry. +// +// The CSV contains the following columns in order: +// - "Project" : entry.ProjectName +// - "Start Time" : entry.StartTime formatted as "2006-01-02 15:04:05" +// - "End Time" : entry.EndTime formatted as "2006-01-02 15:04:05" or +// an empty string if EndTime is nil +// - "Duration (hours)" : entry.Duration() expressed in hours, formatted +// with two decimal places +// - "Description" : entry.Description +// +// The function returns an error if the file cannot be created or if writing +// any header/record fails. The CSV writer is flushed before returning, and the +// file is closed via deferred cleanup. The caller should ensure the provided +// filename is writable. +func ToCSV(entries []*storage.TimeEntry, filename string) error { + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create CSV file: %w", err) + } + + defer file.Close() + + writer := csv.NewWriter(file) + + defer writer.Flush() + + header := []string{"Project", "Start Time", "End Time", "Duration (hours)", "Description"} + if err := writer.Write(header); err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + + for _, entry := range entries { + endTime := "" + if entry.EndTime != nil { + endTime = entry.EndTime.Format("2006-01-02 15:04:05") + } + + duration := entry.Duration().Hours() + + record := []string{ + entry.ProjectName, + entry.StartTime.Format("2006-01-02 15:04:05"), + endTime, + fmt.Sprintf("%.2f", duration), + entry.Description, + } + + if err := writer.Write(record); err != nil { + return fmt.Errorf("failed to write record: %w", err) + } + } + + return nil +} \ No newline at end of file diff --git a/internal/export/json.go b/internal/export/json.go new file mode 100644 index 0000000..fe671f2 --- /dev/null +++ b/internal/export/json.go @@ -0,0 +1,73 @@ +package export + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/storage" +) + +// ExportEntry represents a single time-tracking record prepared for JSON export. +// It contains the project name, the start timestamp, an optional end timestamp, +// the duration expressed in hours, and an optional human-readable description. +// +// Project is the associated project identifier or name. +// StartTime is the entry start timestamp as a string (for example, RFC3339). +// EndTime is the optional end timestamp; it will be omitted from JSON when empty. +// Duration is the total duration of the entry in hours as a floating-point value. +// Description is an optional text note; it will be omitted from JSON when empty. +type ExportEntry struct { + Project string `json:"project"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time,omitempty"` + Duration float64 `json:"duration_hours"` + Description string `json:"description,omitempty"` +} + +// ToJson writes the given time entries to filename in pretty-printed JSON. +// Each storage.TimeEntry is converted to an ExportEntry with these mappings: +// - Project: entry.ProjectName +// - StartTime: formatted using layout "2006-01-02T15:04:05Z07:00" (RFC3339-like) +// - EndTime: formatted using the same layout if entry.EndTime is non-nil; omitted otherwise +// - Duration: entry.Duration().Hours() (floating-point hours) +// - Description: entry.Description +// +// The function creates or truncates the target file, encodes the slice of +// ExportEntry values with json.Encoder and indentation, and closes the file +// before returning. It returns an error if the file cannot be created or if +// JSON encoding fails. Callers must ensure the destination path is writable. +func ToJson(entries []*storage.TimeEntry, filename string) error { + var exportEntries []ExportEntry + + for _, entry := range entries { + export := ExportEntry{ + Project: entry.ProjectName, + StartTime: entry.StartTime.Format("2006-01-02T15:04:05Z07:00"), + Duration: entry.Duration().Hours(), + Description: entry.Description, + } + + if entry.EndTime != nil { + export.EndTime = entry.EndTime.Format("2006-01-02T15:04:05Z07:00") + } + + 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() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + if err := encoder.Encode(exportEntries); err != nil { + return fmt.Errorf("failed to encode JSON: %w", err) + } + + return nil +} \ No newline at end of file From 17d5b11dd952b5edb4ee8c513d8c9e6301a22705 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 7 Dec 2025 21:35:03 -0800 Subject: [PATCH 5/6] Add stats command for time tracking statistics Introduces a new 'stats' command to display time tracking statistics by day, week, or all-time. Also includes minor formatting fixes in export.go and log.go command definitions. --- cmd/export.go | 2 +- cmd/log.go | 2 +- cmd/stats.go | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 cmd/stats.go diff --git a/cmd/export.go b/cmd/export.go index 9d270c7..051e054 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -22,7 +22,7 @@ var ( var exportCmd = &cobra.Command{ Use: "export", Short: "Export time entries", - Long: `Export time tracking data to different formats.`, + Long: `Export time tracking data to different formats.`, Run: func(cmd *cobra.Command, args []string) { db, err := storage.Initialize() if err != nil { diff --git a/cmd/log.go b/cmd/log.go index 9d93ef1..5b36bb3 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -19,7 +19,7 @@ var ( var logCmd = &cobra.Command{ Use: "log", Short: "View time tracking history", - Long: `Display past time tracking entries with optional filtering.`, + Long: `Display past time tracking entries with optional filtering.`, Run: func(cmd *cobra.Command, args []string) { db, err := storage.Initialize() diff --git a/cmd/stats.go b/cmd/stats.go new file mode 100644 index 0000000..7cced95 --- /dev/null +++ b/cmd/stats.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/DylanDevelops/tmpo/internal/config" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/spf13/cobra" +) + +var ( + statsToday bool + statsWeek bool +) + +var statsCmd = &cobra.Command{ + Use: "stats", + Short: "Show time tracking statistics", + Long: `Display statistics and summaries of your time tracking data.`, + Run: func(cmd *cobra.Command, args []string) { + db, err := storage.Initialize() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + defer db.Close() + + var start, end time.Time + var periodName string + + if statsToday { + start = time.Now().Truncate(24 * time.Hour) + end = start.Add(24 * time.Hour) + periodName = "Today" + } else if statsWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + + start = now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end = start.AddDate(0, 0, 7) + periodName = "This week" + } else { + entries, err := db.GetEntries(0) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + ShowAllTimeStats(entries, db) + + return + } + + entries, err := db.GetEntriesByDateRange(start, end) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + ShowPeriodStats(entries, periodName) + }, +} + +func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { + if len(entries) == 0 { + fmt.Printf("No entries for %s.\n", periodName) + + return + } + + projectStats := make(map[string]time.Duration) + var totalDuration time.Duration + + for _, entry := range entries { + duration := entry.Duration() + projectStats[entry.ProjectName] += duration + totalDuration += duration + } + + fmt.Printf("\n[tmpo] Stats for %s\n\n", periodName) + fmt.Printf(" Total Time: %s (%.2f hours)\n", formatDuration(totalDuration), totalDuration.Hours()) + fmt.Printf(" Total Entries: %d\n\n", len(entries)) + + fmt.Println(" By Project:") + for project, duration := range projectStats { + percentage := (duration.Seconds() / totalDuration.Seconds()) * 100 + fmt.Printf(" %-20s %s (%.1f%%)\n", project, formatDuration(duration), percentage) + } + + cfg, _, _ := config.FindAndLoad() + if cfg != nil && cfg.HourlyRate > 0 { + earnings := totalDuration.Hours() * cfg.HourlyRate + fmt.Printf("\n Estimated Earnings: $%.2f (at $%.2f/hr)\n", earnings, cfg.HourlyRate) + } +} + +func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { + if len(entries) == 0 { + fmt.Println("No entries found.") + + return + } + + projectStats := make(map[string]time.Duration) + var totalDuration time.Duration + + for _, entry := range entries { + duration := entry.Duration() + projectStats[entry.ProjectName] += duration + totalDuration += duration + } + + projects, _ := db.GetAllProjects() + + fmt.Printf("\n[tmpo] All-Time Statistics\n") + fmt.Printf(" Total Time: %s (%.2f hours)\n", formatDuration(totalDuration), totalDuration.Hours()) + fmt.Printf(" Total Entries: %d\n", len(entries)) + fmt.Printf(" Projects Tracked: %d\n\n", len(projects)) + + fmt.Println(" By Project:") + for project, duration := range projectStats { + percentage := (duration.Seconds() / totalDuration.Seconds()) * 100 + fmt.Printf(" %-20s %s (%.1f%%)\n", project, formatDuration(duration), percentage) + } +} + +func init() { + rootCmd.AddCommand(statsCmd) + + statsCmd.Flags().BoolVarP(&statsToday, "today", "t", false, "Show today's stats") + statsCmd.Flags().BoolVarP(&statsWeek, "week", "w", false, "Show this week's stats") +} From 0b11f1af0344e3fa94d39493a2eb558483c9bf95 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 7 Dec 2025 21:42:18 -0800 Subject: [PATCH 6/6] Add documentation for stats reporting functions Added detailed doc comments for ShowPeriodStats and ShowAllTimeStats functions in cmd/stats.go, describing their behavior, aggregation logic, and output. This improves code readability and helps future maintainers understand the purpose and implementation details of these functions. --- cmd/stats.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cmd/stats.go b/cmd/stats.go index 7cced95..5b1dcbb 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -70,6 +70,22 @@ var statsCmd = &cobra.Command{ }, } +// ShowPeriodStats prints aggregated statistics for a named period to standard +// output. Given a slice of *storage.TimeEntry and a human-readable periodName, +// the function: +// +// - returns early with a message if entries is empty, +// - computes and prints the total accumulated time and its hour equivalent, +// - prints the total number of entries, +// - aggregates time by project and prints a per-project line with duration and +// percentage of the total, +// - attempts to load configuration and, if a positive hourly rate is present, +// prints an estimated earnings line. +// +// Aggregation is done via a map[string]time.Duration; iteration order is +// therefore non-deterministic. Percentages are computed as projectSeconds / +// totalSeconds * 100, so if the total duration is zero the percentage values +// may be undefined (NaN/Inf). All output is produced using fmt. func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { if len(entries) == 0 { fmt.Printf("No entries for %s.\n", periodName) @@ -103,6 +119,21 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { } } +// ShowAllTimeStats prints aggregated all-time statistics to standard output. +// Given a slice of *storage.TimeEntry and a pointer to the database, the +// function: +// +// - returns early with a message if entries is empty, +// - computes and prints the total accumulated time and its hour equivalent, +// - prints the total number of entries and number of tracked projects, +// - aggregates time by project and prints a per-project line with duration and +// percentage of the total. +// +// The function fetches the list of projects from the provided database to +// determine the number of projects tracked. Aggregation is done via a +// map[string]time.Duration; iteration order is therefore non-deterministic. +// If the total duration is zero, percentage values may be undefined. All +// output is produced using fmt. func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { if len(entries) == 0 { fmt.Println("No entries found.")