Convert duration in seconds to hours and minutes and skip empty values

Viewed 242

We have a Time Tracking model with a duration (in seconds) value that is always greater than 60 (1 minute).

I need to convert the duration to hours and minutes if they are not zero and without zero at the start of hours or minutes. For example:

duration1 = 63000  # expected value:  17 h 30 m
duration2 = 28800  # expected value:  8 h
duration3 = 1800   # expected value:  30 m
duration4 = 300    # expected value:  5 m

I almost did, but have small problem with a zero values:

Time.at(duration1).utc.strftime('%H h %M m').sub!(/^0/, '') 
# 17 h 30 m

Time.at(duration2).utc.strftime('%H h %M m').sub!(/^0/, '') 
# 8 h 00 m

Time.at(duration3).utc.strftime('%H h %M m').sub!(/^0/, '') 
# 0 h 30 m

Time.at(duration4).utc.strftime('%H h %M m').sub!(/^0/, '') 
# 0 h 05 m

Thanks for answers.

6 Answers

Why not just something simple like this:

def to_hms(time)
  hours = time / 3600
  minutes = (time / 60) % 60

  if (hours > 0 and minutes > 0)
    '%d h %d m' % [ hours, minutes ]
  elsif (hours > 0)
    '%d h' % hours
  elsif (minutes > 0)
    '%d m' % minutes
  end
end

Where this produces the desired results:

to_hms(63000)
# => "17 h 30 m"
to_hms(28800)
# => "8 h"
to_hms(1800)
# => "30 m"

Use

.gsub(/\b0+(?:\B|\s[hm](?:\s|$))/, '')

See proof.

Explanation

--------------------------------------------------------------------------------
  \b                       the boundary between a word char (\w) and
                           something that is not a word char
--------------------------------------------------------------------------------
  0+                       '0' (1 or more times (matching the most
                           amount possible))
--------------------------------------------------------------------------------
  (?:                      group, but do not capture:
--------------------------------------------------------------------------------
    \B                       the boundary between two word chars (\w)
                             or two non-word chars (\W)
--------------------------------------------------------------------------------
   |                        OR
--------------------------------------------------------------------------------
    \s                       whitespace (\n, \r, \t, \f, and " ")
--------------------------------------------------------------------------------
    [hm]                     any character of: 'h', 'm'
--------------------------------------------------------------------------------
    (?:                      group, but do not capture:
--------------------------------------------------------------------------------
      \s                       whitespace (\n, \r, \t, \f, and " ")
--------------------------------------------------------------------------------
     |                        OR
--------------------------------------------------------------------------------
      $                        before an optional \n, and the end of
                               the string
--------------------------------------------------------------------------------
    )                        end of grouping
--------------------------------------------------------------------------------
  )                        end of grouping

You can either match 00 followed by h or m, or you can match a 0 and assert a digit 1-9 directly to the right followed by either h or m

\b(?:00 [hm]|0(?=[1-9] [hm]))\s*

Rubular demo and a Ruby demo.

Time.at(duration1).utc.strftime('%H h %M m').gsub(/\b(?:00 [hm]|0(?=[1-9] [hm]))\s*/, ''))

Taking a page from @Tadman's answer, I suggest the following.

def doit(duration)
  hr, min = (duration/60).divmod(60)
  case
  when hr == 0  then "#{min} m"
  when min == 0 then "#{hr} h"
  else               "#{hr} h, #{min} m" 
  end
end

doit 63000  #=> "17 h 30 m"
doit 28800  #=> "8 h"
doit 1800   #=> "30 m"
doit 0      #=> "0 m" 

See Integer#divmod (which references Numeric#divmod). For example,

(63000/60).divmod(60) #=> [17, 1800]
  #=> [17, 30]

I'll leave my origin answer below for anyone wanting to practice the construction of regular expressions, but I cannot recommend its use in practice, or for that matter, the use of a regular expression generally (or conversion to a Time object).

def doit(duration)
  hrs, secs = duration.divmod(3600)
  ("%d h %d m" % [hrs, secs/60]).gsub(/\A0 h |(?<=h) 0 m\z/, '')
end
doit 63000  #=> "17 h 30 m"
doit 28800  #=> "8 h"
doit 1800   #=> "30 m"
doit 0      #=> "" 

I will explain the regular expression below.


Notice that doit 0 above returns an empty string. If it is desired that "0 m" be returned in that case the regular expression can be modified as follows.

def doit(duration)
  hrs, secs = duration.divmod(3600)
  ("%d h %d m" % [hrs, secs/60]).gsub(/\A0 h |(?<=h)(?<!\A0 h) 0 m\z/, '')
end
doit 63000  #=> "17 h 30 m"
doit 28800  #=> "8 h"
doit 1800   #=> "30 m"
doit 0      #=> "0 m" 

The second regular expression can be written in free-spacing mode to make it self-documenting.

/
\A          # match beginning of string
0[ ]h[ ]    # match '0 h '
|           # or 
(?<=h)      # use postive lookbehind to assert the current match is preceded by 'h'
(?<!\A0 h)  # use negative lookbehind to assert the current match not preceded by
            # '0 h' at the beginning of the string
0[ ]m       # match '0 m'
\z          # invoke free-spacing regex definition mode

The first regular espression above differs from this one only that it does not contain the negative lookbehind.

When using free-spacing mode the regex engine removes all whitespace outside comments before parsing the expression. Spaces that are part of the expression must therefore be protected. I've done that be put each space in a character class. The are other ways to do that, one being to escape spaces (\ ).

If you are using Rails 6.1+, you can try to utilize the following function:


def format_seconds(numeric)
  seconds = numeric.abs.seconds

  hours   = seconds.in_hours.floor.hours
  minutes = seconds.in_minutes.minutes - hours.in_minutes.minutes

  hours   = hours.parts[:hours].to_i
  minutes = minutes.parts[:minutes].to_i

  formatted =
    case
    when hours.nonzero? && minutes.nonzero?
      "#{hours} h #{minutes} m"
    when hours.nonzero?
      "#{hours} h"
    when minutes.nonzero?
      "#{minutes} m"
    else
      '0 m'
    end

  formatted = "-#{formatted}" if numeric.negative?

  formatted
end

# format_seconds(63_000)
# => "17 h 30 m"

# format_seconds(28_800)
# => "8 h"

# format_seconds(1_800)
# => "30 m"

# format_seconds(0)
# => "0 m"

# format_seconds(1_000_000)
# => "277 h 46 m"

# format_seconds(-1_000_000)
# => "-277 h 46 m"

# format_seconds(300.25)
# => "5 m"

Sources:

Just for fun, chaining Kernel#then, returning a hash with data, manipulate it rejecting zeros, etc.

duration0 = 322200

res = duration0.divmod(24*60*60).then do |day, sec|
  sec.divmod(60*60).then do |hour, sec|
    sec.divmod(60).then do |min, sec|
      {d: day, h: hour, m: min, s: sec}
    end
  end
end.reject { |_, v| v.zero? }.map { |k, v| "#{v}#{k}"}.join(' ')

res #=> "3d 17h 30m"

You could add days to hours if you want: {h: 24 * days + hour, m: min, s: sec}.

Related