diff --git a/pkg/web/html/overtimereport_view.go b/pkg/web/html/overtimereport_view.go index a7664cc..4329dab 100644 --- a/pkg/web/html/overtimereport_view.go +++ b/pkg/web/html/overtimereport_view.go @@ -26,7 +26,7 @@ func (v *OvertimeReportView) formatDailySummary(daily *timesheet.DailySummary) V basic := Values{ "Weekday": daily.Date.Weekday(), "Date": daily.Date.Format(odoo.DateFormat), - "OvertimeHours": strconv.FormatFloat(daily.CalculateOvertime().Hours(), 'f', 2, 64), + "OvertimeHours": formatDurationInHours(daily.CalculateOvertime()), "LeaveType": "", } if daily.HasAbsences() { @@ -35,9 +35,25 @@ func (v *OvertimeReportView) formatDailySummary(daily *timesheet.DailySummary) V return basic } +// formatDurationInHours returns a human friendly "0:00"-formatted duration. +// Seconds within a minute are rounded up or down to the nearest full minute. +// A sign ("-") is prefixed if duration is negative. +func formatDurationInHours(d time.Duration) string { + sign := "" + if d.Seconds() < 0 { + sign = "-" + d = time.Duration(d.Nanoseconds() * -1) + } + d = d.Round(time.Minute) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + return fmt.Sprintf("%s%d:%02d", sign, h, m) +} + func (v *OvertimeReportView) formatSummary(s timesheet.Summary) Values { return Values{ - "TotalOvertime": s.TotalOvertime.Truncate(time.Minute), + "TotalOvertime": formatDurationInHours(s.TotalOvertime.Truncate(time.Minute)), // TODO: Might not be accurate for days before 2021 "TotalLeaves": fmt.Sprintf("%sd", strconv.FormatFloat(s.TotalLeaveDays.Hours()/8, 'f', 0, 64)), } diff --git a/pkg/web/html/overtimereport_view_test.go b/pkg/web/html/overtimereport_view_test.go new file mode 100644 index 0000000..11518d8 --- /dev/null +++ b/pkg/web/html/overtimereport_view_test.go @@ -0,0 +1,85 @@ +package html + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOvertimeReportView_formatDurationHumanFriendly(t *testing.T) { + tests := map[string]struct { + givenDuration time.Duration + expectedOutcome string + }{ + "GivenNoDuration_ThenReturnZero": { + givenDuration: time.Duration(0), + expectedOutcome: "0:00", + }, + "GivenPositiveDuration_WhenDurationMoreThan30s_ThenRoundUp": { + givenDuration: parseDuration(t, "30s"), + expectedOutcome: "0:01", + }, + "GivenPositiveDuration_WhenDurationMoreThan30s_ThenRoundUpEdgeCase": { + givenDuration: parseDuration(t, "1m59s"), + expectedOutcome: "0:02", + }, + "GivenPositiveDuration_WhenDurationLessThan30s_ThenRoundDown": { + givenDuration: parseDuration(t, "29s"), + expectedOutcome: "0:00", + }, + "GivenPositiveDuration_WhenUnder1Hour_ThenReturnMinutesOnly": { + givenDuration: parseDuration(t, "38m"), + expectedOutcome: "0:38", + }, + "GivenPositiveDuration_WhenOver1Hour_ThenReturnHoursAndMinutes": { + givenDuration: parseDuration(t, "1h38m"), + expectedOutcome: "1:38", + }, + "GivenPositiveDuration_WhenOver10Hour_ThenReturnHoursAndMinutes": { + givenDuration: parseDuration(t, "10h38m"), + expectedOutcome: "10:38", + }, + "GivenPositiveDuration_WhenOver100Hour_ThenReturnHoursAndMinutes": { + givenDuration: parseDuration(t, "100h38m"), + expectedOutcome: "100:38", + }, + "GivenNegativeDuration_WhenDurationMoreThan30s_ThenRoundUp": { + givenDuration: parseDuration(t, "-30s"), + expectedOutcome: "-0:01", + }, + "GivenNegativeDuration_WhenDurationLessThan30s_ThenRoundDown": { + givenDuration: parseDuration(t, "-29s"), + expectedOutcome: "-0:00", + }, + "GivenNegativeDuration_WhenUnder1Hour_ThenReturnMinutesOnly": { + givenDuration: parseDuration(t, "-38m"), + expectedOutcome: "-0:38", + }, + "GivenNegativeDuration_WhenOver1Hour_ThenReturnHoursAndMinutes": { + givenDuration: parseDuration(t, "-1h38m"), + expectedOutcome: "-1:38", + }, + "GivenNegativeDuration_WhenOver10Hour_ThenReturnHoursAndMinutes": { + givenDuration: parseDuration(t, "-10h38m"), + expectedOutcome: "-10:38", + }, + "GivenNegativeDuration_WhenOver100Hour_ThenReturnHoursAndMinutes": { + givenDuration: parseDuration(t, "-100h38m"), + expectedOutcome: "-100:38", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + result := formatDurationInHours(tt.givenDuration) + assert.Equal(t, tt.expectedOutcome, result) + }) + } +} + +func parseDuration(t *testing.T, format string) time.Duration { + d, err := time.ParseDuration(format) + require.NoError(t, err) + return d +}