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 +7 -0
- data/CHANGELOG.md +19 -0
- data/README.md +282 -0
- data/exe/whenwords +156 -0
- data/lib/whenwords/version.rb +5 -0
- data/lib/whenwords.rb +291 -0
- metadata +53 -0
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
|
+
[](https://badge.fury.io/rb/whenwords)
|
|
4
|
+
[](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
|
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: []
|