diff --git a/cmd/entries/manual.go b/cmd/entries/manual.go index 406ce0c..a56ecd3 100644 --- a/cmd/entries/manual.go +++ b/cmd/entries/manual.go @@ -167,7 +167,7 @@ func ManualCmd() *cobra.Command { } if entry.HourlyRate != nil { - earnings := duration.Hours() * *entry.HourlyRate + earnings := entry.RoundedHours() * *entry.HourlyRate fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), fmt.Sprintf("$%.2f", *entry.HourlyRate)) fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), fmt.Sprintf("$%.2f", earnings)) } diff --git a/cmd/history/stats.go b/cmd/history/stats.go index e96d579..2340f47 100644 --- a/cmd/history/stats.go +++ b/cmd/history/stats.go @@ -111,7 +111,7 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { totalDuration += duration if entry.HourlyRate != nil { - earnings := duration.Hours() * *entry.HourlyRate + earnings := entry.RoundedHours() * *entry.HourlyRate projectEarnings[entry.ProjectName] += earnings totalEarnings += earnings hasAnyEarnings = true @@ -184,7 +184,7 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { totalDuration += duration if entry.HourlyRate != nil { - earnings := duration.Hours() * *entry.HourlyRate + earnings := entry.RoundedHours() * *entry.HourlyRate projectEarnings[entry.ProjectName] += earnings totalEarnings += earnings hasAnyEarnings = true diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 25fb944..a35a8f9 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -477,6 +477,53 @@ func TestTimeEntryIsRunning(t *testing.T) { } } +func TestTimeEntryRoundedHours(t *testing.T) { + tests := []struct { + name string + entry *TimeEntry + expected float64 + }{ + { + name: "1h 49m 46s rounds to 1.83 hours", + entry: &TimeEntry{ + StartTime: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2024, 1, 1, 11, 49, 46, 0, time.UTC)), + }, + expected: 1.83, + }, + { + name: "2h 30m 0s rounds to 2.50 hours", + entry: &TimeEntry{ + StartTime: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2024, 1, 1, 12, 30, 0, 0, time.UTC)), + }, + expected: 2.50, + }, + { + name: "0h 5m 30s rounds to 0.09 hours", + entry: &TimeEntry{ + StartTime: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2024, 1, 1, 10, 5, 30, 0, time.UTC)), + }, + expected: 0.09, + }, + { + name: "3h 14m 56s rounds to 3.25 hours", + entry: &TimeEntry{ + StartTime: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2024, 1, 1, 13, 14, 56, 0, time.UTC)), + }, + expected: 3.25, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.entry.RoundedHours()) + }) + } +} + // Helper functions func floatPtr(f float64) *float64 { return &f diff --git a/internal/storage/models.go b/internal/storage/models.go index b885288..f99b3de 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -1,6 +1,9 @@ package storage -import "time" +import ( + "math" + "time" +) // TimeEntry represents a recorded period of work on a project. // It includes a unique identifier, the project name, the start time, @@ -31,4 +34,16 @@ func (t *TimeEntry) Duration() time.Duration { // It returns true when EndTime is nil, indicating no end timestamp has been set. func (t *TimeEntry) IsRunning() bool { return t.EndTime == nil +} + +// RoundedHours returns the duration in hours rounded to 2 decimal places. +// This rounding is used for earnings calculations to ensure transparency: +// the displayed hours value (e.g., "1.83 hours") matches exactly what is +// used in billing calculations. +// +// Future enhancement: This could be made configurable via user settings +// to support different rounding increments (e.g., 0.1 hours for 6-minute billing, +// or 0.25 hours for 15-minute billing). +func (t *TimeEntry) RoundedHours() float64 { + return math.Round(t.Duration().Hours()*100) / 100 } \ No newline at end of file