From 270f0da22ac68a4c44209e355b2c3e2fb61d479c Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 14 Dec 2025 21:38:42 -0700 Subject: [PATCH 1/2] Add hourly rate tracking and earnings estimation Introduce an optional hourly rate to time entries, storing it in the database and exposing it via the TimeEntry model. Update the start command to use the configured hourly rate if available, and enhance stats reporting to estimate and display earnings per project and in total. Update all relevant database queries and models to support the new hourly_rate field. --- cmd/start.go | 20 ++++++--- cmd/stats.go | 51 +++++++++++++++++----- internal/storage/db.go | 87 +++++++++++++++++++++++++++----------- internal/storage/models.go | 4 +- 4 files changed, 120 insertions(+), 42 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 11606f9..6cb33ee 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -32,7 +32,7 @@ var startCmd = &cobra.Command{ } if running != nil { - fmt.Fprintf(os.Stderr, "Error: Already tracking time for `%s\n", running.ProjectName) + fmt.Fprintf(os.Stderr, "Error: Already tracking time for `%s`\n", running.ProjectName) fmt.Println("Use 'tmpo stop' to stop the current session first.") os.Exit(1) @@ -41,16 +41,22 @@ var startCmd = &cobra.Command{ projectName, err := DetectProjectName() if err != nil { fmt.Fprintf(os.Stderr, "Error detecting project: %v\n", err) - + os.Exit(1) } - + description := "" if len(args) > 0 { description = args[0] } - entry, err := db.CreateEntry(projectName, description) + // Load config to get hourly rate if available + var hourlyRate *float64 + if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { + hourlyRate = &cfg.HourlyRate + } + + entry, err := db.CreateEntry(projectName, description, hourlyRate) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -60,11 +66,11 @@ var startCmd = &cobra.Command{ fmt.Printf("[tmpo] Started tracking time for '%s'\n", entry.ProjectName) if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil { - fmt.Println(" Source: .tmporc") + fmt.Println(" Config Source: .tmporc") } else if project.IsInGitRepo() { - fmt.Println(" Source: git repository") + fmt.Println(" Config Source: git repository") } else { - fmt.Println(" Source: directory name") + fmt.Println(" Config Source: directory name") } if description != "" { diff --git a/cmd/stats.go b/cmd/stats.go index 5b1dcbb..5ec7981 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -5,7 +5,6 @@ import ( "os" "time" - "github.com/DylanDevelops/tmpo/internal/config" "github.com/DylanDevelops/tmpo/internal/storage" "github.com/spf13/cobra" ) @@ -45,7 +44,7 @@ var statsCmd = &cobra.Command{ start = now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) end = start.AddDate(0, 0, 7) - periodName = "This week" + periodName = "This Week" } else { entries, err := db.GetEntries(0) if err != nil { @@ -94,28 +93,41 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { } 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 := duration.Hours() * *entry.HourlyRate + projectEarnings[entry.ProjectName] += earnings + totalEarnings += earnings + hasAnyEarnings = true + } } 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.Printf(" Total Entries: %d\n", len(entries)) + if hasAnyEarnings { + fmt.Printf(" Total Estimated Earnings: $%.2f\n", totalEarnings) + } + + fmt.Println() 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) + if earnings, ok := projectEarnings[project]; ok && earnings > 0 { + fmt.Printf(" └─ Estimated Earnings: $%.2f\n", earnings) + } } } @@ -137,17 +149,27 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { if len(entries) == 0 { fmt.Println("No entries found.") - + return } 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 := duration.Hours() * *entry.HourlyRate + projectEarnings[entry.ProjectName] += earnings + totalEarnings += earnings + hasAnyEarnings = true + } } projects, _ := db.GetAllProjects() @@ -155,12 +177,21 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { 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.Printf(" Projects Tracked: %d\n", len(projects)) + if hasAnyEarnings { + fmt.Printf(" Total Estimated Earnings: $%.2f\n", totalEarnings) + } + + fmt.Println() 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) + + if earnings, ok := projectEarnings[project]; ok && earnings > 0 { + fmt.Printf(" └─ Estimated Earnings: $%.2f\n", earnings) + } } } diff --git a/internal/storage/db.go b/internal/storage/db.go index 5f70e1b..e6a1a06 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -65,7 +65,8 @@ func Initialize() (*Database, error) { project_name TEXT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME, - description TEXT + description TEXT, + hourly_rate REAL ) `) @@ -77,22 +78,29 @@ func Initialize() (*Database, error) { } // CreateEntry inserts a new time entry for the specified projectName with the given -// description. The entry's start_time is set to the current time. On success it returns +// description and hourlyRate. The entry's start_time is set to the current time. +// If hourlyRate is nil, the hourly_rate column will be set to NULL. On success it returns // the created *TimeEntry (retrieved by querying the database for the last insert id). // If the insert or the subsequent retrieval fails, an error wrapping the underlying // database error is returned. -func (d *Database) CreateEntry(projectName, description string) (*TimeEntry, error) { +func (d *Database) CreateEntry(projectName, description string, hourlyRate *float64) (*TimeEntry, error) { + var rate sql.NullFloat64 + if hourlyRate != nil { + rate = sql.NullFloat64{Float64: *hourlyRate, Valid: true} + } + result, err := d.db.Exec( - "INSERT INTO time_entries (project_name, start_time, description) VALUES (?, ?, ?)", + "INSERT INTO time_entries (project_name, start_time, description, hourly_rate) VALUES (?, ?, ?, ?)", projectName, time.Now(), description, + rate, ) if err != nil { return nil, fmt.Errorf("failed to create entry: %w", err) } - + id, err := result.LastInsertId() if err != nil { @@ -109,20 +117,22 @@ func (d *Database) CreateEntry(projectName, description string) (*TimeEntry, err // If there is no running entry, GetRunningEntry returns (nil, nil). If the database // query or scan fails, it returns a non-nil error describing the failure. // -// The function scans id, project_name, start_time, end_time and description into a +// The function scans id, project_name, start_time, end_time, description and hourly_rate into a // TimeEntry. The EndTime field on the returned TimeEntry is set only if the scanned -// end_time is non-NULL (sql.NullTime.Valid). +// end_time is non-NULL (sql.NullTime.Valid). The HourlyRate field is set only if the scanned +// hourly_rate is non-NULL (sql.NullFloat64.Valid). func (d *Database) GetRunningEntry() (*TimeEntry, error) { var entry TimeEntry var endTime sql.NullTime + var hourlyRate sql.NullFloat64 err := d.db.QueryRow(` - SELECT id, project_name, start_time, end_time, description + SELECT id, project_name, start_time, end_time, description, hourly_rate FROM time_entries WHERE end_time IS NULL ORDER BY start_time DESC LIMIT 1 - `).Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + `).Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description, &hourlyRate) if err == sql.ErrNoRows { return nil, nil @@ -136,6 +146,10 @@ func (d *Database) GetRunningEntry() (*TimeEntry, error) { entry.EndTime = &endTime.Time } + if hourlyRate.Valid { + entry.HourlyRate = &hourlyRate.Float64 + } + return &entry, nil } @@ -159,21 +173,23 @@ func (d *Database) StopEntry(id int64) error { } // GetEntry retrieves a TimeEntry by its ID from the database. -// It queries the time_entries table for id, project_name, start_time, end_time and description, +// It queries the time_entries table for id, project_name, start_time, end_time, description and hourly_rate, // scans the result into a TimeEntry value, and returns a pointer to it. // If the end_time column is NULL in the database, the returned TimeEntry.EndTime will be nil; -// otherwise EndTime will point to the retrieved time value. +// otherwise EndTime will point to the retrieved time value. If the hourly_rate column is NULL, +// the returned TimeEntry.HourlyRate will be nil; otherwise HourlyRate will point to the retrieved value. // If no row is found or an error occurs during query/scan, an error is returned (wrapped with // the context "failed to get entry"). func (d *Database) GetEntry(id int64) (*TimeEntry, error) { var entry TimeEntry var endTime sql.NullTime + var hourlyRate sql.NullFloat64 err := d.db.QueryRow(` - SELECT id, project_name, start_time, end_time, description + SELECT id, project_name, start_time, end_time, description, hourly_rate FROM time_entries WHERE id = ? - `, id).Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + `, id).Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description, &hourlyRate) if err != nil { return nil, fmt.Errorf("failed to get entry: %w", err) @@ -183,6 +199,10 @@ func (d *Database) GetEntry(id int64) (*TimeEntry, error) { entry.EndTime = &endTime.Time } + if hourlyRate.Valid { + entry.HourlyRate = &hourlyRate.Float64 + } + return &entry, nil } @@ -191,14 +211,15 @@ func (d *Database) GetEntry(id int64) (*TimeEntry, error) { // 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. +// will be nil when the corresponding end_time column in the database is NULL. The HourlyRate +// field will be nil when the corresponding hourly_rate 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 +// description and hourly_rate. 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 + SELECT id, project_name, start_time, end_time, description, hourly_rate FROM time_entries ORDER BY start_time DESC ` @@ -211,7 +232,7 @@ func (d *Database) GetEntries(limit int) ([]*TimeEntry, error) { if err != nil { return nil, fmt.Errorf("failed to query entries: %w", err) } - + defer rows.Close() var entries []*TimeEntry @@ -219,8 +240,9 @@ func (d *Database) GetEntries(limit int) ([]*TimeEntry, error) { for rows.Next() { var entry TimeEntry var endTime sql.NullTime + var hourlyRate sql.NullFloat64 - err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description, &hourlyRate) if err != nil { return nil, fmt.Errorf("failed to scan entry: %w", err) } @@ -229,6 +251,10 @@ func (d *Database) GetEntries(limit int) ([]*TimeEntry, error) { entry.EndTime = &endTime.Time } + if hourlyRate.Valid { + entry.HourlyRate = &hourlyRate.Float64 + } + entries = append(entries, &entry) } @@ -240,6 +266,8 @@ func (d *Database) GetEntries(limit int) ([]*TimeEntry, error) { // // 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. +// If the hourly_rate column is NULL the returned TimeEntry.HourlyRate will be nil; +// otherwise HourlyRate will point to the scanned float64. // // 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 @@ -247,7 +275,7 @@ func (d *Database) GetEntries(limit int) ([]*TimeEntry, error) { // 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 + SELECT id, project_name, start_time, end_time, description, hourly_rate FROM time_entries WHERE project_name = ? ORDER BY start_time DESC @@ -264,8 +292,9 @@ func (d *Database) GetEntriesByProject(projectName string) ([]*TimeEntry, error) for rows.Next() { var entry TimeEntry var endTime sql.NullTime + var hourlyRate sql.NullFloat64 - err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description, &hourlyRate) if err != nil { return nil, fmt.Errorf("failed to scan entry: %w", err) } @@ -274,6 +303,10 @@ func (d *Database) GetEntriesByProject(projectName string) ([]*TimeEntry, error) entry.EndTime = &endTime.Time } + if hourlyRate.Valid { + entry.HourlyRate = &hourlyRate.Float64 + } + entries = append(entries, &entry) } @@ -284,10 +317,11 @@ func (d *Database) GetEntriesByProject(projectName string) ([]*TimeEntry, error) // 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. +// For rows with a NULL hourly_rate the returned TimeEntry.HourlyRate will be nil; otherwise HourlyRate points to the parsed float64. // 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 + SELECT id, project_name, start_time, end_time, description, hourly_rate FROM time_entries WHERE start_time BETWEEN ? AND ? ORDER BY start_time DESC @@ -298,14 +332,15 @@ func (d *Database) GetEntriesByDateRange(start, end time.Time) ([]*TimeEntry, er } defer rows.Close() - + var entries []*TimeEntry - + for rows.Next() { var entry TimeEntry var endTime sql.NullTime + var hourlyRate sql.NullFloat64 - err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description, &hourlyRate) if err != nil { return nil, fmt.Errorf("failed to scan entry: %w", err) } @@ -314,6 +349,10 @@ func (d *Database) GetEntriesByDateRange(start, end time.Time) ([]*TimeEntry, er entry.EndTime = &endTime.Time } + if hourlyRate.Valid { + entry.HourlyRate = &hourlyRate.Float64 + } + entries = append(entries, &entry) } diff --git a/internal/storage/models.go b/internal/storage/models.go index fa1c72e..b885288 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -5,13 +5,15 @@ 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. +// a free-form description of the work performed, and an optional hourly rate +// (nil indicates no rate was configured when the entry was created). type TimeEntry struct { ID int64 ProjectName string StartTime time.Time EndTime *time.Time Description string + HourlyRate *float64 } // Duration returns the elapsed time for the TimeEntry. From e25f1046fe4ad39cc7189337a07cb697dab3301f Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 14 Dec 2025 21:55:32 -0700 Subject: [PATCH 2/2] Fix time formatting to use leading zero in log output Updated time formatting in log command to use '03:04 PM' instead of '3:04 PM', ensuring times are displayed with a leading zero for single-digit hours. --- cmd/log.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/log.go b/cmd/log.go index 5b36bb3..6672f24 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -82,9 +82,9 @@ var logCmd = &cobra.Command{ duration := entry.Duration() totalDuration += duration - timeRange := entry.StartTime.Format("3:04 PM") + timeRange := entry.StartTime.Format("03:04 PM") if entry.EndTime != nil { - timeRange += " - " + entry.EndTime.Format("3:04 PM") + timeRange += " - " + entry.EndTime.Format("03:04 PM") } else { timeRange += " - (running)" }