32

I'm trying to find the best way to generate the following output

<name> job took 30 seconds
<name> job took 1 minute and 20 seconds
<name> job took 30 minutes and 1 second
<name> job took 3 hours and 2 minutes

I started this code

def time_range_details
  time = (self.created_at..self.updated_at).count
  sync_time = case time 
    when 0..60 then "#{time} secs"       
    else "#{time/60} minunte(s) and #{time-min*60} seconds"
  end
end

Is there a more efficient way of doing this. It seems like a lot of redundant code for something super simple.

Another use for this is:

<title> was posted 20 seconds ago
<title> was posted 2 hours ago

The code for this is similar, but instead i use Time.now:

def time_since_posted
  time = (self.created_at..Time.now).count
  ...
  ...
end
csanz
  • 445
  • 1
  • 5
  • 10

6 Answers6

74

If you need something more "precise" than distance_of_time_in_words, you can write something along these lines:

def humanize(secs)
  [[60, :seconds], [60, :minutes], [24, :hours], [Float::INFINITY, :days]].map{ |count, name|
    if secs > 0
      secs, n = secs.divmod(count)

      "#{n.to_i} #{name}" unless n.to_i==0
    end
  }.compact.reverse.join(' ')
end

p humanize 1234
#=>"20 minutes 34 seconds"
p humanize 12345
#=>"3 hours 25 minutes 45 seconds"
p humanize 123456
#=>"1 days 10 hours 17 minutes 36 seconds"
p humanize(Time.now - Time.local(2010,11,5))
#=>"4 days 18 hours 24 minutes 7 seconds"

Oh, one remark on your code:

(self.created_at..self.updated_at).count

is really bad way to get the difference. Use simply:

self.updated_at - self.created_at
Rohit Banga
  • 18,458
  • 31
  • 113
  • 191
Mladen Jablanović
  • 43,461
  • 10
  • 90
  • 113
  • just curious, why is it bad? (self.created_at..self.updated_at).count thanks for the clean answer! – csanz Nov 09 '10 at 18:46
  • 3
    @csanz: `.count` in that case iterates through an array of every second between the two timestamps and counts them. As if you would calculate result of expression `100-50` by actually counting all the numbers between 50 and 100. – Mladen Jablanović Nov 09 '10 at 19:59
  • 2
    I prefer the DateHelper methods. If you're going to bother to convert it into English, then you probably don't want to combine days and seconds. It's an artificial precision. – Mark Thomas Nov 09 '10 at 20:50
  • True, but largely depending on context. For example, I needed something similar for displaying duration of each item in a table. I tried DateHelper first, but later replaced it with custom method which prints something like `5:30:25`, rigt aligned. Lot more readable in a tabular data than "fuzzy" expressions from DH. – Mladen Jablanović Nov 09 '10 at 21:13
  • That's precisely my point. There are times to use a data format, in which precision is important, and there are times to use conversational Engish, where too much precision gets in the way. – Mark Thomas Nov 10 '10 at 03:03
  • I modified this a bit and threw in a module for flexible use: https://gist.github.com/jonathansimmons/24fa43ac6ee53819fb93 Specifically making each section, (seconds, minutes, hours, days), optional and used pluralize to return a string like `1 day 10 hours` vs `1 days 10 hours` the code above returns. – JonathanSimmons Sep 24 '15 at 16:03
  • 2
    This code is great, but it doesn't work if for anything over 1,000 days (86_400_000 seconds). `humanize(86_400_001) => "0 days 0 hours 0 minutes 1 seconds"` is wrong. This bug can be fixed by updating the last element of the array to `[Float::INFINITY, :days]`. – Powers Oct 26 '15 at 16:54
  • DateHelper is good, but if you want something customizable, then the answer above is a good sample. So, *you* can avoid the artificial precision by just taking the first two elements of the resulting array. You can decide how to handle values over too many days. – Marlin Pierce Jun 19 '18 at 14:40
33

There are two methods in DateHelper that might give you what you want:

  1. time_ago_in_words

    time_ago_in_words( 1234.seconds.from_now ) #=> "21 minutes"
    
    time_ago_in_words( 12345.seconds.ago )     #=> "about 3 hours"
    
  2. distance_of_time_in_words

    distance_of_time_in_words( Time.now, 1234.seconds.from_now ) #=> "21 minutes"
    
    distance_of_time_in_words( Time.now, 12345.seconds.ago )     #=> "about 3 hours"
    
Joshua Pinter
  • 45,245
  • 23
  • 243
  • 245
Doug R
  • 5,749
  • 2
  • 28
  • 32
  • 1
    Note that `distance_of_time_in_words` is i18n-aware and has a double interface : it can be used directly on a duration : `distance_of_time_in_words(6270.873) #=> environ 2 heures` (in French) – Cyril Duchon-Doris Feb 26 '19 at 20:20
8

chronic_duration parses numeric time to readable and vice versa

usr
  • 81
  • 1
2

If you want to show significant durations in the seconds to days range, an alternative would be (as it doesn't have to perform the best):

def human_duration(secs, significant_only = true)
  n = secs.round
  parts = [60, 60, 24, 0].map{|d| next n if d.zero?; n, r = n.divmod d; r}.
    reverse.zip(%w(d h m s)).drop_while{|n, u| n.zero? }
  if significant_only
    parts = parts[0..1] # no rounding, sorry
    parts << '0' if parts.empty?
  end
  parts.flatten.join
end
start = Time.now
# perform job
puts "Elapsed time: #{human_duration(Time.now - start)}"

human_duration(0.3) == '0'
human_duration(0.5) == '1s'
human_duration(60) == '1m0s'
human_duration(4200) == '1h10m'
human_duration(3600*24) == '1d0h'
human_duration(3600*24 + 3*60*60) == '1d3h'
human_duration(3600*24 + 3*60*60 + 59*60) == '1d3h' # simple code, doesn't round
human_duration(3600*24 + 3*60*60 + 59*60, false) == '1d3h59m0s'

Alternatively you may be only interested in stripping the seconds part when it doesn't matter (also demonstrating another approach):

def human_duration(duration_in_seconds)
  n = duration_in_seconds.round
  parts = []
  [60, 60, 24].each{|d| n, r = n.divmod d; parts << r; break if n.zero?}
  parts << n unless n.zero?
  pairs = parts.reverse.zip(%w(d h m s)[-parts.size..-1])
  pairs.pop if pairs.size > 2 # do not report seconds when irrelevant
  pairs.flatten.join
end

Hope that helps.

rosenfeld
  • 1,730
  • 15
  • 19
1

There is problem with distance_of_time_in_words if u ll pass there 1 hour 30 min it ll return about 2 hours

Simply add in helper:

 PERIODS = {
   'day' => 86400,
   'hour' => 3600,
   'minute' => 60
   }


def formatted_time(total)
  return 'now' if total.zero?

  PERIODS.map do |name, span|
    next if span > total
    amount, total = total.divmod(span)
    pluralize(amount, name)
  end.compact.to_sentence
end

Basically just pass your data in seconds.

Denis
  • 143
  • 1
  • 6
0

Rails has a DateHelper for views. If that is not exactly what you want, you may have to write your own.

@Mladen Jablanović has an answer with good sample code. However, if you don't mind continuing to customize a sample humanize method, this might be a good starting point.

def humanized_array_secs(sec)
  [[60, 'minutes '], [60, 'hours '], [24, 'days ']].inject([[sec, 'seconds']]) do |ary, (count, next_name)|
    div, prev_name = ary.pop

    quot, remain = div.divmod(count)
    ary.push([remain, prev_name])
    ary.push([quot, next_name])
    ary
  end.reverse
end

This gives you an array of values and unit names that you can manipulate.

If the first element is non-zero, it is the number of days. You may want to write code to handle multiple days, like showing weeks, months, and years. Otherwise, trim off the leading 0 values, and take the next two.

def humanized_secs(sec)
  return 'now' if 1 > sec

  humanized_array = humanized_array_secs(sec.to_i)
  days = humanized_array[-1][0]
  case
    when 366 <= days
      "#{days / 365} years"
    when 31 <= days
      "#{days / 31} months"
    when 7 <= days
      "#{days / 7} weeks"
    else
      while humanized_array.any? && (0 == humanized_array[-1][0])
        humanized_array.pop
      end
      humanized_array.reverse[0..1].flatten.join
  end
end

The code even finds use for a ruby while statement.

Marlin Pierce
  • 9,931
  • 4
  • 30
  • 52