Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/entries/manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/history/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions internal/storage/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion internal/storage/models.go
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
}