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)" } 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.