CrackedRuby logo

CrackedRuby

Chronic Duration

A comprehensive guide to parsing time durations from natural language strings using the Chronic Duration Ruby gem.

Standard Library Date and Time Extensions
4.12.2

Overview

Chronic Duration provides natural language parsing for time durations in Ruby. The gem converts human-readable duration strings like "3 hours and 30 minutes" or "2 weeks" into precise numeric values representing seconds.

The library centers around the ChronicDuration module, which exposes parsing and formatting methods. The primary parsing method accepts strings containing duration expressions and returns integer values representing total seconds. The gem handles complex duration combinations, multiple time units, and various natural language patterns.

require 'chronic_duration'

ChronicDuration.parse('30 minutes')
# => 1800

ChronicDuration.parse('2 hours and 15 minutes')  
# => 8100

ChronicDuration.parse('1 week 2 days 3 hours')
# => 694800

Chronic Duration supports parsing dozens of time unit formats including seconds, minutes, hours, days, weeks, months, and years. The parser recognizes singular and plural forms, abbreviated units like "mins" and "hrs", and connecting words such as "and" or commas between duration components.

The gem also provides output formatting through the output method, which converts seconds back into readable duration strings. This bidirectional functionality makes Chronic Duration suitable for both parsing user input and displaying duration information.

Basic Usage

The ChronicDuration.parse method forms the core parsing functionality. Pass duration strings to receive seconds as integers:

ChronicDuration.parse('5 minutes')       # => 300
ChronicDuration.parse('2 hours')         # => 7200  
ChronicDuration.parse('1 day')           # => 86400
ChronicDuration.parse('3 weeks')         # => 1814400

Multiple time units combine naturally using connecting words or punctuation:

ChronicDuration.parse('1 hour 30 minutes')      # => 5400
ChronicDuration.parse('2 days and 3 hours')     # => 183600
ChronicDuration.parse('1 week, 2 days, 3 hours') # => 694800

The parser accepts abbreviated time units and alternative phrasing:

ChronicDuration.parse('30 mins')         # => 1800
ChronicDuration.parse('2 hrs')           # => 7200
ChronicDuration.parse('45 secs')         # => 45
ChronicDuration.parse('1.5 hours')       # => 5400
ChronicDuration.parse('half an hour')    # => 1800

Options control parsing behavior. The :keep_zero option preserves zero values rather than returning nil:

ChronicDuration.parse('0 minutes')                    # => nil
ChronicDuration.parse('0 minutes', keep_zero: true)   # => 0

The :hours_per_day and :days_per_month options define conversion ratios for longer time periods:

ChronicDuration.parse('1 day', hours_per_day: 8)     # => 28800 (8 hours)
ChronicDuration.parse('1 month', days_per_month: 30) # => 2592000 (30 days)

The ChronicDuration.output method converts seconds back to human-readable strings:

ChronicDuration.output(3661)              # => "1 hr 1 min 1 sec"
ChronicDuration.output(7200)              # => "2 hrs"
ChronicDuration.output(90061, format: :long) # => "1 day 1 hour 1 minute 1 second"

Format options control output verbosity. The :short format uses minimal abbreviations, :long format provides full words, and :micro format uses single characters:

ChronicDuration.output(3661, format: :short)  # => "1h 1m 1s"
ChronicDuration.output(3661, format: :long)   # => "1 hour 1 minute 1 second"
ChronicDuration.output(3661, format: :micro)  # => "1h1m1s"

Error Handling & Debugging

Chronic Duration returns nil for unparseable input rather than raising exceptions. This behavior requires explicit nil checking in application code:

duration = ChronicDuration.parse('invalid input')
if duration.nil?
  puts "Could not parse duration"
else
  puts "Duration: #{duration} seconds"
end

Common parsing failures include malformed numbers, unrecognized time units, and ambiguous expressions:

ChronicDuration.parse('five minutes')     # => nil (words, not numbers)
ChronicDuration.parse('5 decades')       # => nil (unsupported unit)
ChronicDuration.parse('5 5 minutes')     # => nil (malformed)
ChronicDuration.parse('')                # => nil (empty string)

Debug parsing issues by testing individual components. Break complex duration strings into smaller parts to identify problematic sections:

# Complex string fails
ChronicDuration.parse('1 day 5 decades 2 hours')  # => nil

# Test individual parts
ChronicDuration.parse('1 day')      # => 86400 (works)
ChronicDuration.parse('5 decades')  # => nil (problem identified)
ChronicDuration.parse('2 hours')    # => 7200 (works)

The :limit_to_hours option restricts parsing to hour-based units and below, preventing ambiguous day/month calculations:

ChronicDuration.parse('2 days', limit_to_hours: true)   # => nil
ChronicDuration.parse('48 hours', limit_to_hours: true) # => 172800

Validate user input before parsing by checking for basic format requirements:

def safe_duration_parse(input)
  return nil if input.nil? || input.strip.empty?
  return nil unless input.match?(/\d/)  # Must contain numbers
  
  result = ChronicDuration.parse(input)
  return nil if result && result < 0    # Reject negative durations
  
  result
end

Handle edge cases around zero values explicitly. The default behavior returns nil for zero durations, which may conflict with application logic:

# Check both nil and zero cases  
def process_duration(input)
  parsed = ChronicDuration.parse(input, keep_zero: true)
  
  case parsed
  when nil
    handle_invalid_input(input)
  when 0
    handle_zero_duration  
  else
    handle_valid_duration(parsed)
  end
end

Large duration values may exceed integer limits or cause overflow issues in calculations. Validate reasonable upper bounds based on application requirements:

MAX_DURATION = 10.years.to_i  # Example limit

def validate_duration(seconds)
  return false if seconds.nil? || seconds < 0
  return false if seconds > MAX_DURATION
  true
end

Common Pitfalls

Natural language parsing creates inherent ambiguity. The same logical duration can be expressed multiple ways, leading to inconsistent parsing results:

ChronicDuration.parse('1.5 hours')      # => 5400
ChronicDuration.parse('1 hour 30 min')  # => 5400  
ChronicDuration.parse('90 minutes')     # => 5400
ChronicDuration.parse('an hour and a half') # => nil (not supported)

Month and year calculations depend on configuration options that may not match user expectations. The default assumes 30 days per month, which differs from calendar months:

# Default behavior
ChronicDuration.parse('1 month')  # => 2592000 (30 days)
ChronicDuration.parse('2 months') # => 5184000 (60 days)

# Calendar-aware alternative needed for precise dates
require 'active_support/core_ext/numeric/time'
1.month.to_i  # => varies by current date

Decimal parsing follows Ruby numeric conversion rules, which can produce unexpected results with malformed input:

ChronicDuration.parse('2.5.5 hours')    # => nil (invalid decimal)
ChronicDuration.parse('2.abc hours')    # => 7200 (parsed as 2.0)
ChronicDuration.parse('.5 hours')       # => 1800 (valid decimal)

Case sensitivity affects parsing success. The parser expects lowercase or standard capitalization patterns:

ChronicDuration.parse('5 MINUTES')     # => nil
ChronicDuration.parse('5 Minutes')     # => 300
ChronicDuration.parse('5 minutes')     # => 300

Negative durations parse successfully but may cause logic errors in applications expecting positive values:

ChronicDuration.parse('-2 hours')      # => -7200
ChronicDuration.parse('minus 1 day')   # => -86400

# Validation needed for positive-only contexts
def positive_duration_parse(input)
  result = ChronicDuration.parse(input)
  result && result > 0 ? result : nil
end

International formatting differences cause parsing failures. The parser expects English language patterns:

ChronicDuration.parse('2 heures')      # => nil (French)
ChronicDuration.parse('2 minutos')     # => nil (Spanish)
ChronicDuration.parse('2 ore')         # => nil (Italian)

Compound expressions with mathematical operations fail to parse:

ChronicDuration.parse('2 + 3 hours')   # => nil
ChronicDuration.parse('5 - 1 minutes') # => nil
ChronicDuration.parse('2 * 3 days')    # => nil

Week calculations may conflict with business logic expecting specific work week definitions:

ChronicDuration.parse('1 week')        # => 604800 (7 full days)
# Business week might be 5 working days = 432000 seconds

Output formatting loses precision for very large durations. Years and months use approximations rather than exact calculations:

seconds_in_year = 365.25 * 24 * 60 * 60  # => 31557600
ChronicDuration.output(seconds_in_year, format: :long)
# May not round-trip perfectly due to month/year approximations

Unicode and special characters in duration strings cause parsing failures:

ChronicDuration.parse('5 minütes')     # => nil
ChronicDuration.parse('2 hours–30')    # => nil (en dash)
ChronicDuration.parse('1½ hours')      # => nil (fraction symbol)

Performance & Memory

Chronic Duration parsing performance depends on string complexity and length. Simple duration strings parse quickly, while complex expressions with multiple time units require more processing:

require 'benchmark'

# Simple parsing - fast
Benchmark.measure do
  1000.times { ChronicDuration.parse('30 minutes') }
end
# => ~0.05 seconds

# Complex parsing - slower  
Benchmark.measure do
  1000.times { ChronicDuration.parse('1 week 2 days 3 hours 45 minutes 30 seconds') }
end
# => ~0.15 seconds

Memory allocation occurs primarily during string processing and regular expression matching. Each parse operation creates temporary string objects during pattern matching:

# Monitor memory usage
require 'objspace'

before = ObjectSpace.count_objects[:T_STRING]
ChronicDuration.parse('2 hours 30 minutes')
after = ObjectSpace.count_objects[:T_STRING]
puts "String objects created: #{after - before}"
# Typically 5-10 string objects per parse

Repeated parsing of identical strings benefits from caching at the application level:

class DurationParser
  def initialize
    @cache = {}
  end
  
  def parse(input)
    return @cache[input] if @cache.key?(input)
    
    result = ChronicDuration.parse(input)
    @cache[input] = result if @cache.size < 1000  # Prevent unbounded growth
    result
  end
end

Batch processing large datasets requires memory management to prevent excessive allocation:

def process_duration_batch(duration_strings)
  results = []
  
  duration_strings.each_slice(100) do |batch|
    batch_results = batch.map { |str| ChronicDuration.parse(str) }
    results.concat(batch_results)
    
    # Force garbage collection periodically
    GC.start if results.size % 1000 == 0
  end
  
  results
end

Output formatting operations allocate string objects proportional to the duration complexity:

# Simple output - minimal allocation
ChronicDuration.output(3600)  # "1 hr"

# Complex output - more allocation  
ChronicDuration.output(694861, format: :long)
# "1 week 2 days 1 hour 1 minute 1 second"

Regular expression compilation happens once per process, but repeated pattern matching dominates parsing time for complex expressions:

# Pre-validate simple cases to skip expensive parsing
def fast_duration_parse(input)
  # Quick numeric-only check
  if input =~ /^\d+\s+(second|minute|hour|day)s?$/i
    return ChronicDuration.parse(input)
  end
  
  # Fall back to full parsing for complex cases
  ChronicDuration.parse(input)
end

Thread safety allows concurrent parsing operations without synchronization overhead. Multiple threads can safely call parse methods simultaneously:

# Parallel processing safe
threads = duration_strings.map do |str|
  Thread.new { ChronicDuration.parse(str) }
end

results = threads.map(&:value)

Production Patterns

Web applications commonly integrate Chronic Duration for parsing user-submitted time values in forms:

class TaskController < ApplicationController
  def create
    duration_input = params[:task][:estimated_duration]
    duration_seconds = ChronicDuration.parse(duration_input)
    
    if duration_seconds && duration_seconds > 0
      @task = Task.create(
        name: params[:task][:name],
        estimated_duration: duration_seconds
      )
      redirect_to @task
    else
      @task = Task.new(task_params)
      @task.errors.add(:estimated_duration, 'Invalid duration format')
      render :new
    end
  end
end

Database storage requires converting parsed seconds to appropriate column types. Store durations as integers representing seconds for precise calculations:

# Migration
class CreateTasks < ActiveRecord::Migration[7.0]
  def change
    create_table :tasks do |t|
      t.string :name, null: false
      t.integer :estimated_duration_seconds
      t.timestamps
    end
  end
end

# Model with duration helpers
class Task < ApplicationRecord
  def estimated_duration=(duration_string)
    self.estimated_duration_seconds = ChronicDuration.parse(duration_string)
  end
  
  def estimated_duration
    return nil unless estimated_duration_seconds
    ChronicDuration.output(estimated_duration_seconds, format: :long)
  end
end

API endpoints validate duration parameters and return structured error responses:

class ApiController < ApplicationController
  def create_timer
    duration_str = params[:duration]
    parsed_duration = validate_duration(duration_str)
    
    if parsed_duration
      timer = Timer.create(duration_seconds: parsed_duration)
      render json: { timer: timer, status: 'created' }
    else
      render json: { 
        error: 'Invalid duration format', 
        examples: ['30 minutes', '2 hours', '1 day 2 hours']
      }, status: 422
    end
  end
  
  private
  
  def validate_duration(input)
    return nil if input.blank?
    
    duration = ChronicDuration.parse(input)
    return nil unless duration
    return nil if duration <= 0 || duration > 7.days.to_i
    
    duration
  end
end

Background job systems use Chronic Duration for scheduling delays and timeouts:

class ProcessDataJob < ApplicationJob
  def perform(data, retry_delay_str = '5 minutes')
    process(data)
  rescue StandardError => e
    retry_delay = ChronicDuration.parse(retry_delay_str) || 300
    
    retry_job(wait: retry_delay.seconds) if executions < 3
    raise e
  end
end

# Usage
ProcessDataJob.perform_later(user_data, '10 minutes')

Monitoring systems track duration parsing failures and performance metrics:

class DurationMetrics
  def self.parse_with_metrics(input)
    start_time = Time.current
    
    result = ChronicDuration.parse(input)
    
    duration = Time.current - start_time
    
    if result.nil?
      Rails.logger.warn("Duration parsing failed for input: #{input}")
      StatsD.increment('duration.parse.failure')
    else
      StatsD.timing('duration.parse.success', duration * 1000)
    end
    
    result
  end
end

Configuration management centralizes parsing options across application components:

# config/initializers/chronic_duration.rb
class DurationConfig
  DEFAULT_OPTIONS = {
    keep_zero: true,
    limit_to_hours: false,
    hours_per_day: 24,
    days_per_month: 30
  }.freeze
  
  def self.parse(input, **options)
    ChronicDuration.parse(input, **DEFAULT_OPTIONS.merge(options))
  end
  
  def self.output(seconds, **options)
    ChronicDuration.output(seconds, **{ format: :long }.merge(options))
  end
end

Reference

Core Methods

Method Parameters Returns Description
ChronicDuration.parse(string, **opts) string (String), options (Hash) Integer or nil Parses duration string into seconds
ChronicDuration.output(seconds, **opts) seconds (Integer), options (Hash) String Formats seconds into readable duration

Parse Options

Option Type Default Description
:keep_zero Boolean false Return 0 instead of nil for zero durations
:limit_to_hours Boolean false Restrict parsing to hours and smaller units
:hours_per_day Integer 24 Hours per day for day calculations
:days_per_month Integer 30 Days per month for month calculations

Output Format Options

Format Example Output Description
:micro "1h30m" Minimal single-character units
:short "1h 30m" Abbreviated units with spaces
:long "1 hour 30 minutes" Full word units

Supported Time Units

Unit Variations Seconds
Second second, seconds, sec, secs, s 1
Minute minute, minutes, min, mins, m 60
Hour hour, hours, hr, hrs, h 3600
Day day, days, d 86400
Week week, weeks, wk, wks, w 604800
Month month, months, mo, mos 2592000*
Year year, years, yr, yrs, y 31536000*

*Approximate values based on configuration options

Common Parsing Patterns

Input Pattern Example Result
Single unit "30 minutes" 1800
Multiple units "1 hour 30 minutes" 5400
With conjunctions "2 days and 3 hours" 183600
Comma separated "1 week, 2 days" 777600
Decimal values "1.5 hours" 5400
Mixed formats "90 mins 30 secs" 5430

Return Values

Condition Return Value
Valid parse Positive integer (seconds)
Zero duration (default) nil
Zero duration (keep_zero: true) 0
Invalid input nil
Empty string nil
Negative duration Negative integer

Error Cases

Input Type Behavior Example
Unrecognized units Returns nil "5 decades"
No numbers Returns nil "five minutes"
Malformed numbers Returns nil "2.5.5 hours"
Empty string Returns nil ""
Non-string input Type error parse(123)