diff --git a/app/models/time_entry.rb b/app/models/time_entry.rb index b784c4f2d..55d714786 100644 --- a/app/models/time_entry.rb +++ b/app/models/time_entry.rb @@ -191,7 +191,12 @@ class TimeEntry < ApplicationRecord def hours h = read_attribute(:hours) if h.is_a?(Float) - h.round(2) + # Convert the float value to a rational to avoid rounding errors + # + # Examples: + # 0.38333333333333336 => (23/60) # 23 minutes + # 1.1166666666666667 => (67/60) # 1 hour 7 minutes + (h * 60).round / 60r else h end diff --git a/lib/redmine/i18n.rb b/lib/redmine/i18n.rb index 83ecb05eb..5267c2336 100644 --- a/lib/redmine/i18n.rb +++ b/lib/redmine/i18n.rb @@ -50,12 +50,12 @@ module Redmine end def l_hours(hours) - hours = hours.to_f + hours = hours.to_r l((hours < 2.0 ? :label_f_hour : :label_f_hour_plural), :value => format_hours(hours)) end def l_hours_short(hours) - l(:label_f_hour_short, :value => format_hours(hours.to_f)) + l(:label_f_hour_short, :value => format_hours(hours.to_r)) end def ll(lang, str, arg=nil) @@ -93,8 +93,10 @@ module Redmine return "" if hours.blank? if Setting.timespan_format == 'minutes' - h = hours.floor - m = ((hours - h) * 60).round + # Ensure the hours value is rational + rational_hours = hours.is_a?(Rational) ? hours : (hours * 60).round / 60r + h = rational_hours.truncate + m = ((rational_hours - h) * 60).round "%d:%02d" % [h, m] else number_with_delimiter(sprintf('%.2f', hours.to_f), delimiter: nil) diff --git a/test/functional/my_controller_test.rb b/test/functional/my_controller_test.rb index b485b5c9a..252338610 100644 --- a/test/functional/my_controller_test.rb +++ b/test/functional/my_controller_test.rb @@ -48,14 +48,24 @@ class MyControllerTest < Redmine::ControllerTest preferences.save! with_issue = TimeEntry.create!( - :user => User.find(2), :spent_on => Date.yesterday, + :user => User.find(2), :spent_on => User.current.today.yesterday, :hours => 2.5, :activity_id => 10, :issue_id => 1 ) without_issue = TimeEntry.create!( - :user => User.find(2), :spent_on => Date.yesterday, + :user => User.find(2), :spent_on => User.current.today.yesterday, :hours => 3.5, :activity_id => 10, :project_id => 1 ) + # Issues that causes a rounding error + # The sum of 10m, 40m and 10m should be 1h, but it may displayed as 1h1m + # if hours are rounded to 2 decimals + # [0.17, 0.67, 0.17].sum => 1.01 + %w[10m 40m 10m].each do |hours| + TimeEntry.create!( + :user => User.find(2), :spent_on => User.current.today, + :hours => hours, :activity_id => 10, :issue_id => 1 + ) + end get :page assert_response :success assert_select "tr#time-entry-#{with_issue.id}" do @@ -65,6 +75,10 @@ class MyControllerTest < Redmine::ControllerTest assert_select "tr#time-entry-#{without_issue.id}" do assert_select 'td.hours', :text => '3:30' end + assert_select 'tr:first' do # today's total + assert_select 'td.hours span.hours-int', :text => '1' + assert_select 'td.hours span.hours-dec', :text => ':00' + end end def test_page_with_assigned_issues_block_and_no_custom_settings diff --git a/test/unit/time_entry_test.rb b/test/unit/time_entry_test.rb index 8722a86cd..7bb28f675 100644 --- a/test/unit/time_entry_test.rb +++ b/test/unit/time_entry_test.rb @@ -87,10 +87,12 @@ class TimeEntryTest < ActiveSupport::TestCase "3 hours" => 3.0, "12min" => 0.2, "12 Min" => 0.2, + "0:23" => Rational(23, 60), # 0.38333333333333336 + "1:07" => Rational(67, 60) # 1.1166666666666667 } assertions.each do |k, v| t = TimeEntry.new(:hours => k) - assert_equal v, t.hours, "Converting #{k} failed:" + assert v == t.hours && t.hours.is_a?(Rational), "Converting #{k} failed:" end end