From 5a3a8802914ca1101ed7cc46b73ffbd47a53e4ab Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Mon, 22 Dec 2025 17:58:47 -0700 Subject: [PATCH 1/2] Use rounded duration for earnings calculations Earnings are now calculated using durations rounded to the nearest minute to better align with typical invoicing practices. Added TimeEntry.RoundedDuration method and corresponding unit tests to ensure correct rounding behavior. --- cmd/entries/manual.go | 5 +++- cmd/history/stats.go | 10 ++++++-- internal/storage/db_test.go | 47 +++++++++++++++++++++++++++++++++++++ internal/storage/models.go | 9 +++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/cmd/entries/manual.go b/cmd/entries/manual.go index 406ce0c..6a60cfa 100644 --- a/cmd/entries/manual.go +++ b/cmd/entries/manual.go @@ -167,7 +167,10 @@ func ManualCmd() *cobra.Command { } if entry.HourlyRate != nil { - earnings := duration.Hours() * *entry.HourlyRate + // Use rounded duration (to nearest minute) for earnings calculation + // to align with typical invoicing practices + roundedDuration := entry.RoundedDuration() + earnings := roundedDuration.Hours() * *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..4d6190b 100644 --- a/cmd/history/stats.go +++ b/cmd/history/stats.go @@ -111,7 +111,10 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { totalDuration += duration if entry.HourlyRate != nil { - earnings := duration.Hours() * *entry.HourlyRate + // Use rounded duration (to nearest minute) for earnings calculation + // to align with typical invoicing practices + roundedDuration := entry.RoundedDuration() + earnings := roundedDuration.Hours() * *entry.HourlyRate projectEarnings[entry.ProjectName] += earnings totalEarnings += earnings hasAnyEarnings = true @@ -184,7 +187,10 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { totalDuration += duration if entry.HourlyRate != nil { - earnings := duration.Hours() * *entry.HourlyRate + // Use rounded duration (to nearest minute) for earnings calculation + // to align with typical invoicing practices + roundedDuration := entry.RoundedDuration() + earnings := roundedDuration.Hours() * *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..fd6d5d2 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -477,6 +477,53 @@ func TestTimeEntryIsRunning(t *testing.T) { } } +func TestTimeEntryRoundedDuration(t *testing.T) { + tests := []struct { + name string + entry *TimeEntry + expected time.Duration + }{ + { + name: "rounds up when seconds >= 30", + 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: 110 * time.Minute, // 1h 49m 46s rounds to 1h 50m + }, + { + name: "rounds down when seconds < 30", + entry: &TimeEntry{ + StartTime: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2024, 1, 1, 11, 49, 15, 0, time.UTC)), + }, + expected: 109 * time.Minute, // 1h 49m 15s rounds to 1h 49m + }, + { + name: "exact minutes remains unchanged", + 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: 150 * time.Minute, // 2h 30m stays as 2h 30m + }, + { + name: "30 seconds rounds up", + 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: 6 * time.Minute, // 5m 30s rounds to 6m + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.entry.RoundedDuration()) + }) + } +} + // Helper functions func floatPtr(f float64) *float64 { return &f diff --git a/internal/storage/models.go b/internal/storage/models.go index b885288..96a194a 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -31,4 +31,13 @@ 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 +} + +// RoundedDuration returns the duration rounded to the nearest minute. +// This is useful for financial calculations where invoicing is typically done +// in minute increments rather than to the second or millisecond. +func (t *TimeEntry) RoundedDuration() time.Duration { + duration := t.Duration() + minutes := duration.Round(time.Minute) + return minutes } \ No newline at end of file From 5d6667915a939f4838c83f91fa292628537db7f9 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Mon, 22 Dec 2025 22:36:11 -0700 Subject: [PATCH 2/2] Refactor earnings calculation to use RoundedHours Replaces usage of RoundedDuration with a new RoundedHours method for earnings calculations, ensuring hours are rounded to two decimal places for transparency and consistency in billing. Updates related tests and documentation to reflect this change. --- cmd/entries/manual.go | 5 +---- cmd/history/stats.go | 10 ++-------- internal/storage/db_test.go | 28 ++++++++++++++-------------- internal/storage/models.go | 22 ++++++++++++++-------- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/cmd/entries/manual.go b/cmd/entries/manual.go index 6a60cfa..a56ecd3 100644 --- a/cmd/entries/manual.go +++ b/cmd/entries/manual.go @@ -167,10 +167,7 @@ func ManualCmd() *cobra.Command { } if entry.HourlyRate != nil { - // Use rounded duration (to nearest minute) for earnings calculation - // to align with typical invoicing practices - roundedDuration := entry.RoundedDuration() - earnings := roundedDuration.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 4d6190b..2340f47 100644 --- a/cmd/history/stats.go +++ b/cmd/history/stats.go @@ -111,10 +111,7 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { totalDuration += duration if entry.HourlyRate != nil { - // Use rounded duration (to nearest minute) for earnings calculation - // to align with typical invoicing practices - roundedDuration := entry.RoundedDuration() - earnings := roundedDuration.Hours() * *entry.HourlyRate + earnings := entry.RoundedHours() * *entry.HourlyRate projectEarnings[entry.ProjectName] += earnings totalEarnings += earnings hasAnyEarnings = true @@ -187,10 +184,7 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { totalDuration += duration if entry.HourlyRate != nil { - // Use rounded duration (to nearest minute) for earnings calculation - // to align with typical invoicing practices - roundedDuration := entry.RoundedDuration() - earnings := roundedDuration.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 fd6d5d2..a35a8f9 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -477,49 +477,49 @@ func TestTimeEntryIsRunning(t *testing.T) { } } -func TestTimeEntryRoundedDuration(t *testing.T) { +func TestTimeEntryRoundedHours(t *testing.T) { tests := []struct { name string entry *TimeEntry - expected time.Duration + expected float64 }{ { - name: "rounds up when seconds >= 30", + 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: 110 * time.Minute, // 1h 49m 46s rounds to 1h 50m + expected: 1.83, }, { - name: "rounds down when seconds < 30", + 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, 11, 49, 15, 0, time.UTC)), + EndTime: timePtr(time.Date(2024, 1, 1, 12, 30, 0, 0, time.UTC)), }, - expected: 109 * time.Minute, // 1h 49m 15s rounds to 1h 49m + expected: 2.50, }, { - name: "exact minutes remains unchanged", + 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, 12, 30, 0, 0, time.UTC)), + EndTime: timePtr(time.Date(2024, 1, 1, 10, 5, 30, 0, time.UTC)), }, - expected: 150 * time.Minute, // 2h 30m stays as 2h 30m + expected: 0.09, }, { - name: "30 seconds rounds up", + 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, 10, 5, 30, 0, time.UTC)), + EndTime: timePtr(time.Date(2024, 1, 1, 13, 14, 56, 0, time.UTC)), }, - expected: 6 * time.Minute, // 5m 30s rounds to 6m + expected: 3.25, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.entry.RoundedDuration()) + assert.Equal(t, tt.expected, tt.entry.RoundedHours()) }) } } diff --git a/internal/storage/models.go b/internal/storage/models.go index 96a194a..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, @@ -33,11 +36,14 @@ func (t *TimeEntry) IsRunning() bool { return t.EndTime == nil } -// RoundedDuration returns the duration rounded to the nearest minute. -// This is useful for financial calculations where invoicing is typically done -// in minute increments rather than to the second or millisecond. -func (t *TimeEntry) RoundedDuration() time.Duration { - duration := t.Duration() - minutes := duration.Round(time.Minute) - return minutes +// 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