whenwords 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2b632b8d1b88e484cd487236d6f074de258636bbc8485fe633c57f4d0da3a20a
4
+ data.tar.gz: 2c3dd46ebccb057efd996a6c13c287f8a216509ae9037e46d19c37b0ee57026d
5
+ SHA512:
6
+ metadata.gz: f4c62f91e2837875c537c3b2f3fc51fb7f9a7c2855da99e4cd41fd61f4ca6392166da376ddd0b3294a18a39b856fa7cfb6a7ec95f6eb3d520422ce5b7f91cac6
7
+ data.tar.gz: ff07da6df5d2cc3d719019cf309916ac29627c3f974058bf12ea9bdb29de8be6ae4a945ea35111dfa4694b06bcdc7661e8a2006209e958e243fd05b673a8be36
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-01-11
9
+
10
+ ### Added
11
+
12
+ - Initial release
13
+ - `timeago` - Convert timestamps to relative time strings ("3 hours ago", "in 2 days")
14
+ - `duration` - Format seconds to human-readable duration ("2 hours, 30 minutes")
15
+ - `parse_duration` - Parse duration strings to seconds ("2h30m" → 9000)
16
+ - `human_date` - Contextual date formatting ("Yesterday", "Last Friday", "March 15")
17
+ - `date_range` - Smart date range formatting ("January 15–22, 2024")
18
+ - CLI executable `whenwords` with all commands
19
+ - Support for multiple timestamp input types (Integer, Float, Time, DateTime, Date, String)
data/README.md ADDED
@@ -0,0 +1,282 @@
1
+ # Whenwords
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/whenwords.svg)](https://badge.fury.io/rb/whenwords)
4
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.7-red.svg)](https://www.ruby-lang.org/)
5
+
6
+ Human-friendly time formatting and parsing for Ruby. Convert timestamps to readable strings like "3 hours ago" and parse duration strings like "2h 30m" into seconds.
7
+
8
+ ## Features
9
+
10
+ - **timeago** - Convert timestamps to relative time ("3 hours ago", "in 2 days")
11
+ - **duration** - Format seconds to human-readable duration ("2 hours, 30 minutes")
12
+ - **parse_duration** - Parse duration strings to seconds ("2h30m" → 9000)
13
+ - **human_date** - Contextual dates ("Yesterday", "Last Friday", "March 15")
14
+ - **date_range** - Smart date range formatting ("January 15–22, 2024")
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'whenwords'
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ Or install it yourself as:
31
+
32
+ ```bash
33
+ gem install whenwords
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```ruby
39
+ require 'whenwords' # Not needed in Rails (Bundler auto-requires)
40
+
41
+ # Relative time
42
+ Whenwords.timeago(Time.now - 3600, reference: Time.now)
43
+ # => "1 hour ago"
44
+
45
+ # Format duration
46
+ Whenwords.duration(9000)
47
+ # => "2 hours, 30 minutes"
48
+
49
+ # Parse duration
50
+ Whenwords.parse_duration("2h 30m")
51
+ # => 9000
52
+
53
+ # Human-readable date
54
+ Whenwords.human_date(Date.today - 1, reference: Date.today)
55
+ # => "Yesterday"
56
+
57
+ # Date range
58
+ Whenwords.date_range(Date.new(2024, 1, 15), Date.new(2024, 1, 22))
59
+ # => "January 15–22, 2024"
60
+ ```
61
+
62
+ ## Command Line Usage
63
+
64
+ After installing the gem:
65
+
66
+ ```bash
67
+ # Relative time
68
+ whenwords timeago 1704067110 --reference 1704067200
69
+ # => 2 minutes ago
70
+
71
+ # Duration formatting
72
+ whenwords duration 9000 --compact
73
+ # => 2h 30m
74
+
75
+ # Parse duration string
76
+ whenwords parse "2 hours 30 minutes"
77
+ # => 9000
78
+
79
+ # Human-readable date
80
+ whenwords human_date 1705190400 --reference 1705276800
81
+ # => Yesterday
82
+
83
+ # Date range
84
+ whenwords date_range 1705276800 1705881600
85
+ # => January 15–22, 2024
86
+ ```
87
+
88
+ ## API Reference
89
+
90
+ ### timeago(timestamp, reference: nil) → String
91
+
92
+ Returns a human-readable relative time string.
93
+
94
+ ```ruby
95
+ Whenwords.timeago(1704067110, reference: 1704067200)
96
+ # => "2 minutes ago"
97
+
98
+ Whenwords.timeago(Time.now + 3600, reference: Time.now)
99
+ # => "in 1 hour"
100
+ ```
101
+
102
+ **Parameters:**
103
+ - `timestamp` - Unix timestamp (Integer/Float), Time, DateTime, Date, or ISO 8601 String
104
+ - `reference:` - Optional reference timestamp (defaults to same as timestamp)
105
+
106
+ **Thresholds:**
107
+ | Condition | Output |
108
+ |-----------|--------|
109
+ | 0–44 seconds | "just now" |
110
+ | 45–89 seconds | "1 minute ago" |
111
+ | 90 seconds – 44 minutes | "{n} minutes ago" |
112
+ | 45–89 minutes | "1 hour ago" |
113
+ | 90 minutes – 21 hours | "{n} hours ago" |
114
+ | 22–35 hours | "1 day ago" |
115
+ | 36 hours – 25 days | "{n} days ago" |
116
+ | 26–45 days | "1 month ago" |
117
+ | 46–319 days | "{n} months ago" |
118
+ | 320–547 days | "1 year ago" |
119
+ | 548+ days | "{n} years ago" |
120
+
121
+ Future times use "in {n} {units}" format.
122
+
123
+ ### duration(seconds, compact: false, max_units: 2) → String
124
+
125
+ Formats a duration in seconds to a human-readable string.
126
+
127
+ ```ruby
128
+ Whenwords.duration(3661)
129
+ # => "1 hour, 1 minute"
130
+
131
+ Whenwords.duration(3661, compact: true)
132
+ # => "1h 1m"
133
+
134
+ Whenwords.duration(93661, max_units: 3)
135
+ # => "1 day, 2 hours, 1 minute"
136
+ ```
137
+
138
+ **Parameters:**
139
+ - `seconds` - Non-negative number of seconds
140
+ - `compact:` - Use compact format ("2h 30m" vs "2 hours, 30 minutes")
141
+ - `max_units:` - Maximum number of units to display (default: 2)
142
+
143
+ ### parse_duration(string) → Integer
144
+
145
+ Parses a human-written duration string into seconds.
146
+
147
+ ```ruby
148
+ Whenwords.parse_duration("2h30m") # => 9000
149
+ Whenwords.parse_duration("2 hours 30 minutes") # => 9000
150
+ Whenwords.parse_duration("2.5 hours") # => 9000
151
+ Whenwords.parse_duration("2:30") # => 9000 (h:mm)
152
+ Whenwords.parse_duration("1 week") # => 604800
153
+ ```
154
+
155
+ **Accepted formats:**
156
+ - Compact: "2h30m", "2h 30m", "2h, 30m"
157
+ - Verbose: "2 hours 30 minutes", "2 hours and 30 minutes"
158
+ - Decimal: "2.5 hours", "1.5h"
159
+ - Colon: "2:30" (h:mm), "2:30:00" (h:mm:ss)
160
+
161
+ **Unit aliases:**
162
+ - seconds: s, sec, secs, second, seconds
163
+ - minutes: m, min, mins, minute, minutes
164
+ - hours: h, hr, hrs, hour, hours
165
+ - days: d, day, days
166
+ - weeks: w, wk, wks, week, weeks
167
+
168
+ ### human_date(timestamp, reference: nil) → String
169
+
170
+ Returns a contextual date string.
171
+
172
+ ```ruby
173
+ Whenwords.human_date(Date.today, reference: Date.today)
174
+ # => "Today"
175
+
176
+ Whenwords.human_date(Date.today - 3, reference: Date.today)
177
+ # => "Last Friday" (if today is Monday)
178
+ ```
179
+
180
+ **Outputs:**
181
+ - Same day → "Today"
182
+ - Previous day → "Yesterday"
183
+ - Next day → "Tomorrow"
184
+ - Within past 7 days → "Last {weekday}"
185
+ - Within next 7 days → "This {weekday}"
186
+ - Same year → "Month Day"
187
+ - Different year → "Month Day, Year"
188
+
189
+ ### date_range(start, end) → String
190
+
191
+ Formats a date range with smart abbreviation.
192
+
193
+ ```ruby
194
+ Whenwords.date_range(1705276800, 1705363200)
195
+ # => "January 15–16, 2024"
196
+
197
+ Whenwords.date_range(1705276800, 1707955200)
198
+ # => "January 15 – February 15, 2024"
199
+ ```
200
+
201
+ **Behavior:**
202
+ - Same day: "March 5, 2024"
203
+ - Same month: "March 5–7, 2024"
204
+ - Same year: "March 5 – April 7, 2024"
205
+ - Different years: "December 28, 2024 – January 3, 2025"
206
+ - Swapped inputs are auto-corrected
207
+
208
+ ## Rails Integration
209
+
210
+ After adding `gem 'whenwords'` to your Gemfile and running `bundle install`, `Whenwords` is automatically available throughout your Rails app.
211
+
212
+ ### View Helper
213
+
214
+ ```ruby
215
+ # app/helpers/application_helper.rb
216
+ module ApplicationHelper
217
+ def time_ago_in_words(time)
218
+ Whenwords.timeago(time, reference: Time.now)
219
+ end
220
+
221
+ def format_duration(seconds, compact: false)
222
+ Whenwords.duration(seconds, compact: compact)
223
+ end
224
+ end
225
+ ```
226
+
227
+ ### Model
228
+
229
+ ```ruby
230
+ class Video < ApplicationRecord
231
+ def duration_formatted(compact: false)
232
+ Whenwords.duration(duration_seconds, compact: compact)
233
+ end
234
+
235
+ def duration_seconds=(value)
236
+ if value.is_a?(String) && value.match?(/[a-z]/i)
237
+ super(Whenwords.parse_duration(value))
238
+ else
239
+ super
240
+ end
241
+ end
242
+ end
243
+ ```
244
+
245
+ ## Error Handling
246
+
247
+ ```ruby
248
+ begin
249
+ Whenwords.parse_duration("invalid string")
250
+ rescue Whenwords::ParseError => e
251
+ puts "Parse error: #{e.message}"
252
+ end
253
+
254
+ begin
255
+ Whenwords.duration(-100)
256
+ rescue Whenwords::Error => e
257
+ puts "Error: #{e.message}"
258
+ end
259
+ ```
260
+
261
+ ## Accepted Input Types
262
+
263
+ All timestamp parameters accept:
264
+ - `Integer` / `Float` - Unix timestamp (seconds)
265
+ - `Time` - Ruby Time object
266
+ - `DateTime` - Ruby DateTime object
267
+ - `Date` - Ruby Date object
268
+ - `String` - ISO 8601 formatted string
269
+
270
+ ## Development
271
+
272
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake spec` to run the tests.
273
+
274
+ To install this gem onto your local machine, run `bundle exec rake install`.
275
+
276
+ ## Contributing
277
+
278
+ Bug reports and pull requests are welcome on GitHub at https://github.com/dbreunig/whenwords.
279
+
280
+ ## License
281
+
282
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/exe/whenwords ADDED
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "whenwords"
5
+ require "optparse"
6
+
7
+ def main
8
+ if ARGV.empty?
9
+ puts usage
10
+ exit 1
11
+ end
12
+
13
+ command = ARGV.shift
14
+
15
+ case command
16
+ when "timeago"
17
+ handle_timeago
18
+ when "duration"
19
+ handle_duration
20
+ when "parse", "parse_duration"
21
+ handle_parse_duration
22
+ when "human_date"
23
+ handle_human_date
24
+ when "date_range"
25
+ handle_date_range
26
+ when "help", "-h", "--help"
27
+ puts usage
28
+ when "version", "-v", "--version"
29
+ puts "whenwords #{Whenwords::VERSION}"
30
+ else
31
+ puts "Unknown command: #{command}"
32
+ puts usage
33
+ exit 1
34
+ end
35
+ rescue Whenwords::Error => e
36
+ puts "Error: #{e.message}"
37
+ exit 1
38
+ end
39
+
40
+ def usage
41
+ <<~USAGE
42
+ whenwords - Human-friendly time formatting and parsing
43
+
44
+ Usage:
45
+ whenwords <command> [options]
46
+
47
+ Commands:
48
+ timeago <timestamp> [--reference <ref>] Convert timestamp to relative time
49
+ duration <seconds> [--compact] [--max-units N] Format duration
50
+ parse "<duration string>" Parse duration string to seconds
51
+ human_date <timestamp> [--reference <ref>] Format date contextually
52
+ date_range <start> <end> Format date range
53
+
54
+ Examples:
55
+ whenwords timeago 1704067110 --reference 1704067200
56
+ whenwords duration 9000 --compact
57
+ whenwords parse "2 hours 30 minutes"
58
+ whenwords human_date 1705190400 --reference 1705276800
59
+ whenwords date_range 1705276800 1705881600
60
+
61
+ Options:
62
+ -h, --help Show this help message
63
+ -v, --version Show version
64
+ USAGE
65
+ end
66
+
67
+ def handle_timeago
68
+ options = {}
69
+ parser = OptionParser.new do |opts|
70
+ opts.on("--reference REF", "-r REF", Integer, "Reference timestamp") do |r|
71
+ options[:reference] = r
72
+ end
73
+ end
74
+ parser.parse!
75
+
76
+ timestamp = ARGV.shift&.to_i
77
+ unless timestamp
78
+ puts "Error: timestamp required"
79
+ exit 1
80
+ end
81
+
82
+ result = if options[:reference]
83
+ Whenwords.timeago(timestamp, reference: options[:reference])
84
+ else
85
+ Whenwords.timeago(timestamp, reference: Time.now.to_i)
86
+ end
87
+ puts result
88
+ end
89
+
90
+ def handle_duration
91
+ options = { compact: false, max_units: 2 }
92
+ parser = OptionParser.new do |opts|
93
+ opts.on("--compact", "-c", "Use compact format") do
94
+ options[:compact] = true
95
+ end
96
+ opts.on("--max-units N", "-m N", Integer, "Maximum units to display") do |n|
97
+ options[:max_units] = n
98
+ end
99
+ end
100
+ parser.parse!
101
+
102
+ seconds = ARGV.shift&.to_i
103
+ unless seconds
104
+ puts "Error: seconds required"
105
+ exit 1
106
+ end
107
+
108
+ puts Whenwords.duration(seconds, **options)
109
+ end
110
+
111
+ def handle_parse_duration
112
+ input = ARGV.join(" ")
113
+ if input.empty?
114
+ puts "Error: duration string required"
115
+ exit 1
116
+ end
117
+
118
+ puts Whenwords.parse_duration(input)
119
+ end
120
+
121
+ def handle_human_date
122
+ options = {}
123
+ parser = OptionParser.new do |opts|
124
+ opts.on("--reference REF", "-r REF", Integer, "Reference timestamp") do |r|
125
+ options[:reference] = r
126
+ end
127
+ end
128
+ parser.parse!
129
+
130
+ timestamp = ARGV.shift&.to_i
131
+ unless timestamp
132
+ puts "Error: timestamp required"
133
+ exit 1
134
+ end
135
+
136
+ result = if options[:reference]
137
+ Whenwords.human_date(timestamp, reference: options[:reference])
138
+ else
139
+ Whenwords.human_date(timestamp, reference: Time.now.to_i)
140
+ end
141
+ puts result
142
+ end
143
+
144
+ def handle_date_range
145
+ start_ts = ARGV.shift&.to_i
146
+ end_ts = ARGV.shift&.to_i
147
+
148
+ unless start_ts && end_ts
149
+ puts "Error: start and end timestamps required"
150
+ exit 1
151
+ end
152
+
153
+ puts Whenwords.date_range(start_ts, end_ts)
154
+ end
155
+
156
+ main
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whenwords
4
+ VERSION = "0.1.0"
5
+ end
data/lib/whenwords.rb ADDED
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "whenwords/version"
5
+
6
+ module Whenwords
7
+ class Error < StandardError; end
8
+ class ParseError < Error; end
9
+
10
+ SECONDS_PER_MINUTE = 60
11
+ SECONDS_PER_HOUR = 3600
12
+ SECONDS_PER_DAY = 86400
13
+ SECONDS_PER_WEEK = 604800
14
+ SECONDS_PER_MONTH = 2592000 # 30 days
15
+ SECONDS_PER_YEAR = 31536000 # 365 days
16
+
17
+ WEEKDAY_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
18
+ MONTH_NAMES = %w[January February March April May June July August September October November December].freeze
19
+
20
+ class << self
21
+ # Convert various timestamp formats to Unix seconds
22
+ def normalize_timestamp(timestamp)
23
+ case timestamp
24
+ when Integer, Float
25
+ timestamp.to_f
26
+ when Time
27
+ timestamp.to_f
28
+ when DateTime
29
+ timestamp.to_time.to_f
30
+ when Date
31
+ Time.utc(timestamp.year, timestamp.month, timestamp.day).to_f
32
+ when String
33
+ Time.parse(timestamp).to_f
34
+ else
35
+ raise Error, "Invalid timestamp format: #{timestamp.class}"
36
+ end
37
+ end
38
+
39
+ # Returns a human-readable relative time string.
40
+ # @param timestamp [Integer, Float, Time, String] The timestamp to format
41
+ # @param reference [Integer, Float, Time, String, nil] The reference timestamp (defaults to same as timestamp)
42
+ # @return [String] Human-readable relative time
43
+ def timeago(timestamp, reference: nil)
44
+ ts = normalize_timestamp(timestamp)
45
+ ref = reference.nil? ? ts : normalize_timestamp(reference)
46
+
47
+ diff = ref - ts
48
+ future = diff < 0
49
+ diff = diff.abs
50
+
51
+ text = calculate_timeago_text(diff)
52
+
53
+ if text == "just now"
54
+ text
55
+ elsif future
56
+ "in #{text.sub(/ ago$/, '')}"
57
+ else
58
+ text
59
+ end
60
+ end
61
+
62
+ # Formats a duration in seconds to human-readable string.
63
+ # @param seconds [Numeric] Non-negative number of seconds
64
+ # @param compact [Boolean] Use compact format (e.g., "2h 30m")
65
+ # @param max_units [Integer] Maximum number of units to display
66
+ # @return [String] Human-readable duration
67
+ def duration(seconds, compact: false, max_units: 2)
68
+ raise Error, "Duration cannot be negative" if seconds.negative?
69
+ raise Error, "Duration cannot be NaN" if seconds.respond_to?(:nan?) && seconds.nan?
70
+ raise Error, "Duration cannot be infinite" if seconds.respond_to?(:infinite?) && seconds.infinite?
71
+
72
+ seconds = seconds.to_f
73
+
74
+ return compact ? "0s" : "0 seconds" if seconds.zero?
75
+
76
+ units = calculate_duration_units(seconds)
77
+ non_zero_units = units.select { |_, v| v.positive? }
78
+
79
+ # Take only max_units
80
+ display_units = non_zero_units.first(max_units)
81
+
82
+ if compact
83
+ format_compact_duration(display_units)
84
+ else
85
+ format_verbose_duration(display_units)
86
+ end
87
+ end
88
+
89
+ # Parses a human-written duration string into seconds.
90
+ # @param string [String] The duration string to parse
91
+ # @return [Numeric] Duration in seconds
92
+ def parse_duration(string)
93
+ raise ParseError, "Duration string cannot be empty" if string.nil? || string.strip.empty?
94
+
95
+ input = string.strip.downcase
96
+
97
+ # Check for negative values
98
+ raise ParseError, "Negative durations are not allowed" if input.include?("-")
99
+
100
+ # Try colon notation first
101
+ if input.match?(/^\d+:\d{1,2}(:\d{1,2})?$/)
102
+ return parse_colon_notation(input)
103
+ end
104
+
105
+ total = parse_duration_parts(input)
106
+ raise ParseError, "No parseable duration units found" if total.zero? && !input.match?(/\b0\s*[a-z]/)
107
+
108
+ total
109
+ end
110
+
111
+ # Returns a contextual date string.
112
+ # @param timestamp [Integer, Float, Time, String] The date to format
113
+ # @param reference [Integer, Float, Time, String, nil] The reference date
114
+ # @return [String] Human-readable date
115
+ def human_date(timestamp, reference: nil)
116
+ ts = normalize_timestamp(timestamp)
117
+ ref = reference.nil? ? ts : normalize_timestamp(reference)
118
+
119
+ ts_date = Time.at(ts).utc.to_date
120
+ ref_date = Time.at(ref).utc.to_date
121
+
122
+ diff_days = (ts_date - ref_date).to_i
123
+
124
+ case diff_days
125
+ when 0
126
+ "Today"
127
+ when -1
128
+ "Yesterday"
129
+ when 1
130
+ "Tomorrow"
131
+ when -6..-2
132
+ "Last #{WEEKDAY_NAMES[ts_date.wday]}"
133
+ when 2..6
134
+ "This #{WEEKDAY_NAMES[ts_date.wday]}"
135
+ else
136
+ format_date(ts_date, ref_date)
137
+ end
138
+ end
139
+
140
+ # Formats a date range with smart abbreviation.
141
+ # @param start_ts [Integer, Float, Time, String] Start timestamp
142
+ # @param end_ts [Integer, Float, Time, String] End timestamp
143
+ # @return [String] Formatted date range
144
+ def date_range(start_ts, end_ts)
145
+ start_time = normalize_timestamp(start_ts)
146
+ end_time = normalize_timestamp(end_ts)
147
+
148
+ # Swap if start > end
149
+ start_time, end_time = end_time, start_time if start_time > end_time
150
+
151
+ start_date = Time.at(start_time).utc.to_date
152
+ end_date = Time.at(end_time).utc.to_date
153
+
154
+ format_date_range(start_date, end_date)
155
+ end
156
+
157
+ private
158
+
159
+ def calculate_timeago_text(diff)
160
+ case diff
161
+ when 0...45
162
+ "just now"
163
+ when 45...90
164
+ "1 minute ago"
165
+ when 90...(45 * 60)
166
+ "#{(diff / 60.0).round} minutes ago"
167
+ when (45 * 60)...(90 * 60)
168
+ "1 hour ago"
169
+ when (90 * 60)...(22 * 3600)
170
+ "#{(diff / 3600.0).round} hours ago"
171
+ when (22 * 3600)...(36 * 3600)
172
+ "1 day ago"
173
+ when (36 * 3600)...(26 * 86400)
174
+ "#{(diff / 86400.0).round} days ago"
175
+ when (26 * 86400)...(46 * 86400)
176
+ "1 month ago"
177
+ when (46 * 86400)...(320 * 86400)
178
+ "#{(diff / (30.44 * 86400)).round} months ago"
179
+ when (320 * 86400)...(548 * 86400)
180
+ "1 year ago"
181
+ else
182
+ "#{(diff / (365.0 * 86400)).round} years ago"
183
+ end
184
+ end
185
+
186
+ def calculate_duration_units(seconds)
187
+ remaining = seconds.to_i
188
+
189
+ years = remaining / SECONDS_PER_YEAR
190
+ remaining %= SECONDS_PER_YEAR
191
+
192
+ months = remaining / SECONDS_PER_MONTH
193
+ remaining %= SECONDS_PER_MONTH
194
+
195
+ days = remaining / SECONDS_PER_DAY
196
+ remaining %= SECONDS_PER_DAY
197
+
198
+ hours = remaining / SECONDS_PER_HOUR
199
+ remaining %= SECONDS_PER_HOUR
200
+
201
+ minutes = remaining / SECONDS_PER_MINUTE
202
+ secs = remaining % SECONDS_PER_MINUTE
203
+
204
+ {
205
+ years: years,
206
+ months: months,
207
+ days: days,
208
+ hours: hours,
209
+ minutes: minutes,
210
+ seconds: secs
211
+ }
212
+ end
213
+
214
+ def format_compact_duration(units)
215
+ abbreviations = {
216
+ years: "y",
217
+ months: "mo",
218
+ days: "d",
219
+ hours: "h",
220
+ minutes: "m",
221
+ seconds: "s"
222
+ }
223
+
224
+ units.map { |unit, value| "#{value}#{abbreviations[unit]}" }.join(" ")
225
+ end
226
+
227
+ def format_verbose_duration(units)
228
+ units.map do |unit, value|
229
+ unit_name = unit.to_s
230
+ unit_name = unit_name.chomp("s") if value == 1
231
+ "#{value} #{unit_name}"
232
+ end.join(", ")
233
+ end
234
+
235
+ def parse_colon_notation(input)
236
+ parts = input.split(":").map(&:to_i)
237
+
238
+ if parts.length == 2
239
+ # h:mm
240
+ parts[0] * SECONDS_PER_HOUR + parts[1] * SECONDS_PER_MINUTE
241
+ else
242
+ # h:mm:ss
243
+ parts[0] * SECONDS_PER_HOUR + parts[1] * SECONDS_PER_MINUTE + parts[2]
244
+ end
245
+ end
246
+
247
+ def parse_duration_parts(input)
248
+ unit_patterns = {
249
+ /(\d+(?:\.\d+)?)\s*(?:seconds?|secs?|s(?![a-z]))/i => 1,
250
+ /(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|m(?![a-z]))/i => SECONDS_PER_MINUTE,
251
+ /(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h(?![a-z]))/i => SECONDS_PER_HOUR,
252
+ /(\d+(?:\.\d+)?)\s*(?:days?|d(?![a-z]))/i => SECONDS_PER_DAY,
253
+ /(\d+(?:\.\d+)?)\s*(?:weeks?|wks?|w(?![a-z]))/i => SECONDS_PER_WEEK
254
+ }
255
+
256
+ total = 0.0
257
+ found_any = false
258
+
259
+ unit_patterns.each do |pattern, multiplier|
260
+ input.scan(pattern) do |match|
261
+ total += match[0].to_f * multiplier
262
+ found_any = true
263
+ end
264
+ end
265
+
266
+ raise ParseError, "No parseable duration units found" unless found_any
267
+
268
+ total.round
269
+ end
270
+
271
+ def format_date(date, ref_date)
272
+ if date.year == ref_date.year
273
+ "#{MONTH_NAMES[date.month - 1]} #{date.day}"
274
+ else
275
+ "#{MONTH_NAMES[date.month - 1]} #{date.day}, #{date.year}"
276
+ end
277
+ end
278
+
279
+ def format_date_range(start_date, end_date)
280
+ if start_date == end_date
281
+ "#{MONTH_NAMES[start_date.month - 1]} #{start_date.day}, #{start_date.year}"
282
+ elsif start_date.year == end_date.year && start_date.month == end_date.month
283
+ "#{MONTH_NAMES[start_date.month - 1]} #{start_date.day}–#{end_date.day}, #{start_date.year}"
284
+ elsif start_date.year == end_date.year
285
+ "#{MONTH_NAMES[start_date.month - 1]} #{start_date.day} – #{MONTH_NAMES[end_date.month - 1]} #{end_date.day}, #{end_date.year}"
286
+ else
287
+ "#{MONTH_NAMES[start_date.month - 1]} #{start_date.day}, #{start_date.year} – #{MONTH_NAMES[end_date.month - 1]} #{end_date.day}, #{end_date.year}"
288
+ end
289
+ end
290
+ end
291
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: whenwords
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Drew Breunig
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Convert timestamps to readable strings like '3 hours ago' and parse duration
13
+ strings like '2h 30m' into seconds. Includes timeago, duration formatting, duration
14
+ parsing, contextual dates, and date range formatting.
15
+ email:
16
+ - drew@breunig.com
17
+ executables:
18
+ - whenwords
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - README.md
24
+ - exe/whenwords
25
+ - lib/whenwords.rb
26
+ - lib/whenwords/version.rb
27
+ homepage: https://github.com/dbreunig/whenwords
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ homepage_uri: https://github.com/dbreunig/whenwords
32
+ source_code_uri: https://github.com/dbreunig/whenwords
33
+ changelog_uri: https://github.com/dbreunig/whenwords/blob/main/CHANGELOG.md
34
+ documentation_uri: https://github.com/dbreunig/whenwords#readme
35
+ rubygems_mfa_required: 'true'
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 2.7.0
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.6.9
51
+ specification_version: 4
52
+ summary: Human-friendly time formatting and parsing
53
+ test_files: []