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) |