diff --git a/Bugzilla/Util.pm b/Bugzilla/Util.pm index 8a45e98591..8da02640a1 100644 --- a/Bugzilla/Util.pm +++ b/Bugzilla/Util.pm @@ -649,25 +649,26 @@ sub time_ago { # DateTime object or seconds my $ss = ref($param) ? time() - $param->epoch : $param; - my $mm = round($ss / 60); - my $hh = round($ss / (60 * 60)); - my $dd = round($ss / (60 * 60 * 24)); + # Use floor for intermediate calculations to avoid rounding issues + my $mm = floor($ss / 60); + my $hh = floor($ss / (60 * 60)); + my $dd = floor($ss / (60 * 60 * 24)); # They are not the best definition of month and year, # but they should be good enough to be used here. - my $mo = round($ss / (60 * 60 * 24 * 30)); - my $yy = round($ss / (60 * 60 * 24 * 365.2422)); + my $mo = floor($ss / (60 * 60 * 24 * 30)); + my $yy = floor($ss / (60 * 60 * 24 * 365.2422)); return 'Just now' if $ss < 10; return $ss . ' seconds ago' if $ss < 45; - return '1 minute ago' if $ss < 90; + return '1 minute ago' if $mm < 2; return $mm . ' minutes ago' if $mm < 45; - return '1 hour ago' if $mm < 90; + return '1 hour ago' if $hh < 2; return $hh . ' hours ago' if $hh < 24; - return '1 day ago' if $hh < 36; + return '1 day ago' if $dd < 2; return $dd . ' days ago' if $dd < 30; - return '1 month ago' if $dd < 45; + return '1 month ago' if $mo < 2; return $mo . ' months ago' if $mo < 12; - return '1 year ago' if $mo < 18; + return '1 year ago' if $yy < 2; return $yy . ' years ago'; } @@ -976,7 +977,7 @@ sub extract_nicks { # This routine is used to determine the latest versions for a # given product from an external system. We need to fail gracefully -# if the external system is down for any reason. If the call fails, +# if the external system is down for any reason. If the call fails, # then return undefined and let the caller decide what to do. sub fetch_product_versions { my ($product) = @_; diff --git a/js/util.js b/js/util.js index b74e2ee03d..d31e67779d 100644 --- a/js/util.js +++ b/js/util.js @@ -339,34 +339,35 @@ function bz_toggleClass(anElement, aClass) { anElement?.classList.toggle(aClass); } -/* Returns a string representation of a duration. - * - * @param ss Duration in seconds - * or - * @param date Date object +/** + * Returns a string representation of a duration. + * @param {number | Date} param Duration in seconds or a Date object. + * @returns {string} Human-readable time ago string. */ -function timeAgo(param) { - var ss = param.constructor === Date ? Math.round((new Date() - param) / 1000) : param; - var mm = Math.round(ss / 60), - hh = Math.round(ss / (60 * 60)), - dd = Math.round(ss / (60 * 60 * 24)), - // They are not the best definition of month and year, - // but they should be good enough to be used here. - mo = Math.round(ss / (60 * 60 * 24 * 30)), - yy = Math.round(ss / (60 * 60 * 24 * 365.2422)); +const timeAgo = (param) => { + const ss = param.constructor === Date ? Math.round((new Date() - param) / 1000) : param; + // Use Math.floor for intermediate calculations to avoid rounding issues + const mm = Math.floor(ss / 60); + const hh = Math.floor(ss / (60 * 60)); + const dd = Math.floor(ss / (60 * 60 * 24)); + // They are not the best definition of month and year, + // but they should be good enough to be used here. + const mo = Math.floor(ss / (60 * 60 * 24 * 30)); + const yy = Math.floor(ss / (60 * 60 * 24 * 365.2422)); + if (ss < 10) return 'Just now'; - if (ss < 45) return ss + ' seconds ago'; - if (ss < 90) return '1 minute ago'; - if (mm < 45) return mm + ' minutes ago'; - if (mm < 90) return '1 hour ago'; - if (hh < 24) return hh + ' hours ago'; - if (hh < 36) return '1 day ago'; - if (dd < 30) return dd + ' days ago'; - if (dd < 45) return '1 month ago'; - if (mo < 12) return mo + ' months ago'; - if (mo < 18) return '1 year ago'; - return yy + ' years ago'; -} + if (ss < 45) return `${ss} seconds ago`; + if (mm < 2) return '1 minute ago'; + if (mm < 45) return `${mm} minutes ago`; + if (hh < 2) return '1 hour ago'; + if (hh < 24) return `${hh} hours ago`; + if (dd < 2) return '1 day ago'; + if (dd < 30) return `${dd} days ago`; + if (mo < 2) return '1 month ago'; + if (mo < 12) return `${mo} months ago`; + if (yy < 2) return '1 year ago'; + return `${yy} years ago`; +}; /** * Format the given date as Bugzilla’s standard date format. diff --git a/t/007util.t b/t/007util.t index 2e640ae6f9..a248520554 100644 --- a/t/007util.t +++ b/t/007util.t @@ -15,7 +15,7 @@ use warnings; use lib qw(. lib local/lib/perl5 t); use Support::Files; -use Test::More tests => 20; +use Test::More tests => 52; use DateTime; BEGIN { @@ -114,3 +114,56 @@ my ($removed, $added) = diff_arrays(\@old_array, \@new_array); is_deeply($removed, [qw(gamma alpha gamma)], 'diff_array(\@old, \@new) (check removal)'); is_deeply($added, [qw(delta)], 'diff_array(\@old, \@new) (check addition)'); + +# time_ago(): +# Test with seconds +is(time_ago(5), 'Just now', 'time_ago(5) returns "Just now"'); +is(time_ago(9), 'Just now', 'time_ago(9) returns "Just now"'); +is(time_ago(10), '10 seconds ago', 'time_ago(10) returns "10 seconds ago"'); +is(time_ago(30), '30 seconds ago', 'time_ago(30) returns "30 seconds ago"'); +is(time_ago(44), '44 seconds ago', 'time_ago(44) returns "44 seconds ago"'); + +# Test minute boundaries +is(time_ago(45), '1 minute ago', 'time_ago(45) returns "1 minute ago"'); +is(time_ago(60), '1 minute ago', 'time_ago(60) returns "1 minute ago"'); +is(time_ago(90), '1 minute ago', 'time_ago(90) returns "1 minute ago"'); +is(time_ago(119), '1 minute ago', 'time_ago(119) returns "1 minute ago"'); +is(time_ago(120), '2 minutes ago', 'time_ago(120) returns "2 minutes ago"'); + +# Test hour boundaries - critical for the bug fix +is(time_ago(60 * 44), '44 minutes ago', 'time_ago(44 minutes) returns "44 minutes ago"'); +is(time_ago(60 * 60), '1 hour ago', 'time_ago(60 minutes) returns "1 hour ago"'); +is(time_ago(60 * 90), '1 hour ago', 'time_ago(90 minutes) returns "1 hour ago"'); +is(time_ago(60 * 119), '1 hour ago', 'time_ago(119 minutes) returns "1 hour ago"'); +is(time_ago(60 * 120), '2 hours ago', 'time_ago(2 hours) returns "2 hours ago"'); + +# Test day boundaries - this is where the bug was most visible +is(time_ago(60 * 60 * 23), '23 hours ago', 'time_ago(23 hours) returns "23 hours ago"'); +is(time_ago(60 * 60 * 24), '1 day ago', 'time_ago(24 hours) returns "1 day ago"'); +is(time_ago(60 * 60 * 36), '1 day ago', 'time_ago(36 hours) returns "1 day ago"'); +is(time_ago(60 * 60 * 47), '1 day ago', 'time_ago(47 hours) returns "1 day ago"'); +is(time_ago(60 * 60 * 48), '2 days ago', 'time_ago(48 hours) returns "2 days ago"'); +is(time_ago(60 * 60 * 72), '3 days ago', 'time_ago(72 hours) returns "3 days ago"'); + +# Test month boundaries +is(time_ago(60 * 60 * 24 * 29), '29 days ago', 'time_ago(29 days) returns "29 days ago"'); +is(time_ago(60 * 60 * 24 * 30), '1 month ago', 'time_ago(30 days) returns "1 month ago"'); +is(time_ago(60 * 60 * 24 * 45), '1 month ago', 'time_ago(45 days) returns "1 month ago"'); +is(time_ago(60 * 60 * 24 * 59), '1 month ago', 'time_ago(59 days) returns "1 month ago"'); +is(time_ago(60 * 60 * 24 * 60), '2 months ago', 'time_ago(60 days) returns "2 months ago"'); + +# Test year boundaries +is(time_ago(60 * 60 * 24 * 365), '1 year ago', 'time_ago(365 days) returns "1 year ago"'); +is(time_ago(60 * 60 * 24 * 547), '1 year ago', 'time_ago(547 days) returns "1 year ago"'); +is(time_ago(60 * 60 * 24 * 731), '2 years ago', 'time_ago(731 days) returns "2 years ago"'); + +# Test with DateTime object +my $now = DateTime->now(); +my $past = $now->clone->subtract(hours => 25); +is(time_ago($past), '1 day ago', 'time_ago(DateTime 25 hours ago) returns "1 day ago"'); + +$past = $now->clone->subtract(days => 2); +is(time_ago($past), '2 days ago', 'time_ago(DateTime 2 days ago) returns "2 days ago"'); + +$past = $now->clone->subtract(months => 1); +like(time_ago($past), qr/^(1 month|2[89]|3[01] days) ago$/, 'time_ago(DateTime 1 month ago) is reasonable');